Initial commit: YooKassa mock v2
This commit is contained in:
21
.gitignore
vendored
Normal file
21
.gitignore
vendored
Normal file
@@ -0,0 +1,21 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.so
|
||||
.venv/
|
||||
venv/
|
||||
env/
|
||||
.env
|
||||
.env.*
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
.idea/
|
||||
.vscode/
|
||||
.DS_Store
|
||||
16
Dockerfile
Normal file
16
Dockerfile
Normal file
@@ -0,0 +1,16 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV PYTHONDONTWRITEBYTECODE=1
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
ENV MOCK_PORT=8081
|
||||
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
COPY main.py .
|
||||
|
||||
EXPOSE 8081
|
||||
|
||||
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8081"]
|
||||
418
README.md
Normal file
418
README.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# YooKassa Mock v2
|
||||
|
||||
Легковесный mock-сервер для эмуляции базового сценария работы YooKassa.
|
||||
|
||||
## Что умеет
|
||||
|
||||
- создавать платеж
|
||||
- возвращать `confirmation_url`
|
||||
- показывать checkout-страницу
|
||||
- завершать платеж как:
|
||||
- `succeeded`
|
||||
- `canceled`
|
||||
- отправлять webhook
|
||||
- повторять webhook при ошибке
|
||||
- поддерживать `Idempotence-Key`
|
||||
- отдавать статус платежа по `payment_id`
|
||||
|
||||
---
|
||||
|
||||
# Файлы
|
||||
|
||||
- `main.py` — FastAPI приложение
|
||||
- `requirements.txt` — зависимости
|
||||
- `Dockerfile` — образ
|
||||
- `docker-compose.yml` — запуск контейнера
|
||||
- `.env` — переменные окружения
|
||||
|
||||
---
|
||||
|
||||
# Запуск
|
||||
|
||||
## 1. Сборка и старт
|
||||
|
||||
~~~bash
|
||||
docker compose up -d --build
|
||||
~~~
|
||||
|
||||
## 2. Проверка здоровья
|
||||
|
||||
~~~bash
|
||||
curl -s http://127.0.0.1:${YMK_PUBLIC_PORT}/health
|
||||
~~~
|
||||
|
||||
Пример ответа:
|
||||
|
||||
~~~json
|
||||
{"status":"ok","payments_total":0,"idempotence_keys_total":0}
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Переменные окружения
|
||||
|
||||
Пример `.env`:
|
||||
|
||||
~~~env
|
||||
YMK_PUBLIC_PORT=8083
|
||||
|
||||
WEBHOOK_URL=http://192.168.149.101:8000/api/tickets/webhook/yookassa
|
||||
|
||||
MOCK_HOST=192.168.149.101
|
||||
MOCK_PORT=8083
|
||||
|
||||
MOCK_REQUIRE_AUTH=0
|
||||
MOCK_SHOP_ID=test_shop
|
||||
MOCK_SECRET_KEY=test_secret
|
||||
|
||||
WEBHOOK_RETRY_COUNT=3
|
||||
WEBHOOK_RETRY_DELAY_SEC=1
|
||||
WEBHOOK_DELAY_SEC=0
|
||||
~~~
|
||||
|
||||
## Описание
|
||||
|
||||
### `YMK_PUBLIC_PORT`
|
||||
Внешний порт хоста, на который публикуется контейнер.
|
||||
|
||||
### `WEBHOOK_URL`
|
||||
URL для отправки webhook после смены статуса платежа.
|
||||
|
||||
### `MOCK_HOST`
|
||||
Хост, который будет подставляться в `confirmation_url`.
|
||||
|
||||
### `MOCK_PORT`
|
||||
Порт, который будет подставляться в `confirmation_url`.
|
||||
|
||||
### `MOCK_REQUIRE_AUTH`
|
||||
- `0` — Basic Auth не обязателен
|
||||
- `1` — Basic Auth обязателен
|
||||
|
||||
### `MOCK_SHOP_ID`
|
||||
Логин для Basic Auth, если auth включен.
|
||||
|
||||
### `MOCK_SECRET_KEY`
|
||||
Пароль для Basic Auth, если auth включен.
|
||||
|
||||
### `WEBHOOK_RETRY_COUNT`
|
||||
Количество попыток отправки webhook.
|
||||
|
||||
### `WEBHOOK_RETRY_DELAY_SEC`
|
||||
Пауза между retry webhook.
|
||||
|
||||
### `WEBHOOK_DELAY_SEC`
|
||||
Искусственная задержка перед первой отправкой webhook.
|
||||
|
||||
---
|
||||
|
||||
# API
|
||||
|
||||
## 1. POST `/v3/payments`
|
||||
|
||||
Создание платежа.
|
||||
|
||||
### Обязательное
|
||||
- заголовок `Idempotence-Key`
|
||||
- `confirmation.return_url`
|
||||
- `amount.value`
|
||||
|
||||
### Пример запроса
|
||||
|
||||
~~~bash
|
||||
curl -s -X POST http://127.0.0.1:8083/v3/payments \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'Idempotence-Key: test-001' \
|
||||
-d '{
|
||||
"amount": {
|
||||
"value": "100.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"return_url": "http://192.168.149.101:3000/payment/return"
|
||||
},
|
||||
"capture": true,
|
||||
"description": "Заказ №1"
|
||||
}'
|
||||
~~~
|
||||
|
||||
### Пример успешного ответа
|
||||
|
||||
~~~json
|
||||
{
|
||||
"id": "fdb332f8-bb1d-4def-bc1d-d5b00a7e287a",
|
||||
"status": "pending",
|
||||
"paid": false,
|
||||
"amount": {
|
||||
"value": "100.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"confirmation_url": "http://192.168.149.101:8083/checkout?payment_id=fdb332f8-bb1d-4def-bc1d-d5b00a7e287a"
|
||||
},
|
||||
"created_at": "2026-03-12T06:35:47.512Z",
|
||||
"description": "Заказ №1",
|
||||
"metadata": {},
|
||||
"capture": true,
|
||||
"recipient": {
|
||||
"account_id": "test_shop",
|
||||
"gateway_id": "100700"
|
||||
},
|
||||
"refundable": false,
|
||||
"test": true
|
||||
}
|
||||
~~~
|
||||
|
||||
### Идемпотентность
|
||||
|
||||
Если повторно вызвать `POST /v3/payments` с тем же `Idempotence-Key`, мок вернет тот же платеж, а не создаст новый.
|
||||
|
||||
---
|
||||
|
||||
## 2. GET `/v3/payments/{payment_id}`
|
||||
|
||||
Получение статуса платежа.
|
||||
|
||||
### Пример
|
||||
|
||||
~~~bash
|
||||
curl -s http://127.0.0.1:8083/v3/payments/fdb332f8-bb1d-4def-bc1d-d5b00a7e287a
|
||||
~~~
|
||||
|
||||
### Пример ответа
|
||||
|
||||
~~~json
|
||||
{
|
||||
"id": "fdb332f8-bb1d-4def-bc1d-d5b00a7e287a",
|
||||
"status": "succeeded",
|
||||
"paid": true,
|
||||
"amount": {
|
||||
"value": "100.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"confirmation_url": "http://192.168.149.101:8083/checkout?payment_id=fdb332f8-bb1d-4def-bc1d-d5b00a7e287a"
|
||||
},
|
||||
"created_at": "2026-03-12T06:35:47.512Z",
|
||||
"description": "Заказ №1",
|
||||
"metadata": {},
|
||||
"capture": true,
|
||||
"recipient": {
|
||||
"account_id": "test_shop",
|
||||
"gateway_id": "100700"
|
||||
},
|
||||
"refundable": true,
|
||||
"test": true
|
||||
}
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
## 3. GET `/checkout?payment_id=<id>`
|
||||
|
||||
HTML-страница оплаты.
|
||||
|
||||
Что показывает:
|
||||
- `payment_id`
|
||||
- статус
|
||||
- сценарий
|
||||
- описание
|
||||
- сумму
|
||||
|
||||
На странице есть три кнопки:
|
||||
- `Оплатить успешно`
|
||||
- `Отменить оплату`
|
||||
- `Ошибка оплаты`
|
||||
|
||||
---
|
||||
|
||||
## 4. POST `/process/{payment_id}`
|
||||
|
||||
Меняет статус платежа, отправляет webhook, делает redirect на `return_url`.
|
||||
|
||||
### Входные варианты
|
||||
|
||||
Через form-data / x-www-form-urlencoded поле `action`:
|
||||
|
||||
- `success`
|
||||
- `cancel`
|
||||
- `fail`
|
||||
|
||||
### Пример success
|
||||
|
||||
~~~bash
|
||||
curl -i -X POST http://127.0.0.1:8083/process/fdb332f8-bb1d-4def-bc1d-d5b00a7e287a \
|
||||
-d 'action=success'
|
||||
~~~
|
||||
|
||||
### Пример cancel
|
||||
|
||||
~~~bash
|
||||
curl -i -X POST http://127.0.0.1:8083/process/294ec8cd-c1fb-44b4-91cc-04d669343390 \
|
||||
-d 'action=cancel'
|
||||
~~~
|
||||
|
||||
### Поведение
|
||||
|
||||
- `success` → статус `succeeded`, `paid=true`
|
||||
- `cancel` → статус `canceled`, `paid=false`
|
||||
- `fail` → статус `canceled`, `paid=false`
|
||||
|
||||
После этого сервис делает:
|
||||
|
||||
- webhook на `WEBHOOK_URL`
|
||||
- `303 See Other` на `return_url`
|
||||
|
||||
---
|
||||
|
||||
## 5. GET `/health`
|
||||
|
||||
Проверка состояния сервиса.
|
||||
|
||||
### Пример
|
||||
|
||||
~~~bash
|
||||
curl -s http://127.0.0.1:8083/health
|
||||
~~~
|
||||
|
||||
### Ответ
|
||||
|
||||
~~~json
|
||||
{
|
||||
"status": "ok",
|
||||
"payments_total": 2,
|
||||
"idempotence_keys_total": 2
|
||||
}
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Webhook
|
||||
|
||||
После `success`:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"event": "payment.succeeded",
|
||||
"type": "notification",
|
||||
"object": {
|
||||
"id": "payment_id",
|
||||
"status": "succeeded",
|
||||
"paid": true,
|
||||
"amount": {
|
||||
"value": "100.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"description": "Заказ №1",
|
||||
"metadata": {}
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
После `cancel` / `fail`:
|
||||
|
||||
~~~json
|
||||
{
|
||||
"event": "payment.canceled",
|
||||
"type": "notification",
|
||||
"object": {
|
||||
"id": "payment_id",
|
||||
"status": "canceled",
|
||||
"paid": false,
|
||||
"amount": {
|
||||
"value": "250.00",
|
||||
"currency": "RUB"
|
||||
},
|
||||
"description": "Заказ №2",
|
||||
"metadata": {
|
||||
"mock_scenario": "cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Retry webhook
|
||||
|
||||
Если endpoint webhook отвечает ошибкой или недоступен:
|
||||
|
||||
- мок делает повторные попытки
|
||||
- количество попыток задается `WEBHOOK_RETRY_COUNT`
|
||||
- задержка между попытками задается `WEBHOOK_RETRY_DELAY_SEC`
|
||||
|
||||
Логи смотреть так:
|
||||
|
||||
~~~bash
|
||||
docker logs --tail 100 yookassa-mock
|
||||
~~~
|
||||
|
||||
---
|
||||
|
||||
# Сценарии через metadata
|
||||
|
||||
При создании платежа можно задать сценарий по умолчанию:
|
||||
|
||||
~~~json
|
||||
"metadata": {
|
||||
"mock_scenario": "cancel"
|
||||
}
|
||||
~~~
|
||||
|
||||
Допустимые значения:
|
||||
- `success`
|
||||
- `cancel`
|
||||
- `fail`
|
||||
|
||||
Если в `POST /process/{payment_id}` не передать `action`, будет использован `mock_scenario`.
|
||||
|
||||
---
|
||||
|
||||
# Basic Auth
|
||||
|
||||
Если включить:
|
||||
|
||||
~~~env
|
||||
MOCK_REQUIRE_AUTH=1
|
||||
~~~
|
||||
|
||||
Тогда endpoints `/v3/payments` и `/v3/payments/{payment_id}` будут требовать Basic Auth.
|
||||
|
||||
Логин/пароль берутся из:
|
||||
- `MOCK_SHOP_ID`
|
||||
- `MOCK_SECRET_KEY`
|
||||
|
||||
---
|
||||
|
||||
# Ограничения
|
||||
|
||||
Это мок, не полная копия YooKassa.
|
||||
|
||||
Сейчас нет:
|
||||
- persistent storage
|
||||
- списка платежей
|
||||
- ручного cancel endpoint в стиле реального API
|
||||
- полной схемы YooKassa
|
||||
- реальной проверки секретов YooKassa
|
||||
- capture/authorize flow с отдельным подтверждением списания
|
||||
- рефандов
|
||||
|
||||
---
|
||||
|
||||
# Текущее состояние проверки
|
||||
|
||||
Проверено вручную:
|
||||
|
||||
- создание платежа — OK
|
||||
- `confirmation_url` — OK
|
||||
- `GET /v3/payments/{id}` — OK
|
||||
- идемпотентность — OK
|
||||
- `pending -> succeeded` — OK
|
||||
- `pending -> canceled` — OK
|
||||
- redirect на `return_url` — OK
|
||||
- retry webhook — OK
|
||||
|
||||
Проблема сейчас только одна:
|
||||
`WEBHOOK_URL` отвечает `404 Not Found`, значит endpoint на основном backend отсутствует или указан неверно.
|
||||
19
docker-compose.yml
Normal file
19
docker-compose.yml
Normal file
@@ -0,0 +1,19 @@
|
||||
services:
|
||||
yookassa-mock:
|
||||
build: .
|
||||
container_name: yookassa-mock
|
||||
env_file:
|
||||
- .env
|
||||
ports:
|
||||
- "${YMK_PUBLIC_PORT:-8083}:8081"
|
||||
environment:
|
||||
WEBHOOK_URL: "${WEBHOOK_URL}"
|
||||
MOCK_HOST: "${MOCK_HOST}"
|
||||
MOCK_PORT: "${MOCK_PORT:-8081}"
|
||||
MOCK_REQUIRE_AUTH: "${MOCK_REQUIRE_AUTH:-0}"
|
||||
MOCK_SHOP_ID: "${MOCK_SHOP_ID:-}"
|
||||
MOCK_SECRET_KEY: "${MOCK_SECRET_KEY:-}"
|
||||
WEBHOOK_RETRY_COUNT: "${WEBHOOK_RETRY_COUNT:-3}"
|
||||
WEBHOOK_RETRY_DELAY_SEC: "${WEBHOOK_RETRY_DELAY_SEC:-1}"
|
||||
WEBHOOK_DELAY_SEC: "${WEBHOOK_DELAY_SEC:-0}"
|
||||
restart: unless-stopped
|
||||
340
main.py
Normal file
340
main.py
Normal file
@@ -0,0 +1,340 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any, Dict, Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, Header, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from jinja2 import Template
|
||||
|
||||
app = FastAPI(title="YooKassa Mock v2")
|
||||
|
||||
payments_db: Dict[str, Dict[str, Any]] = {}
|
||||
idempotence_db: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
WEBHOOK_URL = os.getenv("WEBHOOK_URL", "").strip()
|
||||
MOCK_HOST = os.getenv("MOCK_HOST", "").strip()
|
||||
MOCK_PORT = os.getenv("MOCK_PORT", "8081").strip()
|
||||
MOCK_REQUIRE_AUTH = os.getenv("MOCK_REQUIRE_AUTH", "0").strip().lower() in {"1", "true", "yes", "on"}
|
||||
MOCK_SHOP_ID = os.getenv("MOCK_SHOP_ID", "").strip()
|
||||
MOCK_SECRET_KEY = os.getenv("MOCK_SECRET_KEY", "").strip()
|
||||
WEBHOOK_RETRY_COUNT = max(1, int(os.getenv("WEBHOOK_RETRY_COUNT", "3")))
|
||||
WEBHOOK_RETRY_DELAY_SEC = float(os.getenv("WEBHOOK_RETRY_DELAY_SEC", "1"))
|
||||
WEBHOOK_DELAY_SEC = float(os.getenv("WEBHOOK_DELAY_SEC", "0"))
|
||||
|
||||
CHECKOUT_TEMPLATE = Template("""
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>YooKassa Mock Checkout</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f6f7fb;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
color: #222;
|
||||
}
|
||||
.card {
|
||||
max-width: 560px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 { margin-top: 0; font-size: 24px; }
|
||||
.amount { font-size: 28px; font-weight: 700; margin: 16px 0 24px; }
|
||||
.row { margin: 8px 0; color: #555; }
|
||||
.actions {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
margin-top: 24px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #006efc;
|
||||
color: white;
|
||||
}
|
||||
button.secondary { background: #6b7280; }
|
||||
button.danger { background: #b91c1c; }
|
||||
code {
|
||||
background: #f3f4f6;
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Тестовая оплата</h1>
|
||||
|
||||
<div class="row">Платеж: <code>{{ payment_id }}</code></div>
|
||||
<div class="row">Статус: <code>{{ status }}</code></div>
|
||||
<div class="row">Сценарий по умолчанию: <code>{{ scenario }}</code></div>
|
||||
<div class="row">Описание: <code>{{ description }}</code></div>
|
||||
|
||||
<div>Сумма к оплате:</div>
|
||||
<div class="amount">{{ amount }} {{ currency }}</div>
|
||||
|
||||
<div class="actions">
|
||||
<form method="post" action="/process/{{ payment_id }}">
|
||||
<input type="hidden" name="action" value="success">
|
||||
<button type="submit">Оплатить успешно</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/process/{{ payment_id }}">
|
||||
<input type="hidden" name="action" value="cancel">
|
||||
<button class="secondary" type="submit">Отменить оплату</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/process/{{ payment_id }}">
|
||||
<input type="hidden" name="action" value="fail">
|
||||
<button class="danger" type="submit">Ошибка оплаты</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
|
||||
def utc_now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat(timespec="milliseconds").replace("+00:00", "Z")
|
||||
|
||||
|
||||
def parse_amount_value(value: Any) -> str:
|
||||
try:
|
||||
normalized = Decimal(str(value)).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError):
|
||||
raise HTTPException(status_code=400, detail="invalid amount.value")
|
||||
if normalized <= 0:
|
||||
raise HTTPException(status_code=400, detail="amount.value must be greater than 0")
|
||||
return f"{normalized:.2f}"
|
||||
|
||||
|
||||
def build_base_url(request: Request) -> str:
|
||||
if MOCK_HOST:
|
||||
return f"http://{MOCK_HOST}:{MOCK_PORT}"
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
def build_payment_response(payment: Dict[str, Any], request: Request) -> Dict[str, Any]:
|
||||
base_url = build_base_url(request)
|
||||
payment_id = payment["id"]
|
||||
return {
|
||||
"id": payment_id,
|
||||
"status": payment["status"],
|
||||
"paid": payment["paid"],
|
||||
"amount": {
|
||||
"value": payment["amount"]["value"],
|
||||
"currency": payment["amount"]["currency"],
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"confirmation_url": f"{base_url}/checkout?payment_id={payment_id}",
|
||||
},
|
||||
"created_at": payment["created_at"],
|
||||
"description": payment["description"],
|
||||
"metadata": payment["metadata"],
|
||||
"capture": payment["capture"],
|
||||
"recipient": {
|
||||
"account_id": payment["recipient"]["account_id"],
|
||||
"gateway_id": payment["recipient"]["gateway_id"],
|
||||
},
|
||||
"refundable": payment["status"] == "succeeded" and bool(payment["capture"]),
|
||||
"test": True,
|
||||
}
|
||||
|
||||
|
||||
def require_basic_auth(authorization: Optional[str]) -> None:
|
||||
if not MOCK_REQUIRE_AUTH:
|
||||
return
|
||||
|
||||
if not authorization or not authorization.startswith("Basic "):
|
||||
raise HTTPException(status_code=401, detail="basic authorization required")
|
||||
|
||||
try:
|
||||
encoded = authorization.split(" ", 1)[1]
|
||||
decoded = base64.b64decode(encoded).decode("utf-8")
|
||||
username, password = decoded.split(":", 1)
|
||||
except Exception:
|
||||
raise HTTPException(status_code=401, detail="invalid basic authorization")
|
||||
|
||||
if username != MOCK_SHOP_ID or password != MOCK_SECRET_KEY:
|
||||
raise HTTPException(status_code=401, detail="invalid shop credentials")
|
||||
|
||||
|
||||
def resolve_event_name(status: str) -> str:
|
||||
if status == "succeeded":
|
||||
return "payment.succeeded"
|
||||
return "payment.canceled"
|
||||
|
||||
|
||||
async def send_webhook(payment: Dict[str, Any]) -> None:
|
||||
if not WEBHOOK_URL:
|
||||
return
|
||||
|
||||
if WEBHOOK_DELAY_SEC > 0:
|
||||
await asyncio.sleep(WEBHOOK_DELAY_SEC)
|
||||
|
||||
payload = {
|
||||
"event": resolve_event_name(payment["status"]),
|
||||
"type": "notification",
|
||||
"object": {
|
||||
"id": payment["id"],
|
||||
"status": payment["status"],
|
||||
"paid": payment["paid"],
|
||||
"amount": payment["amount"],
|
||||
"description": payment["description"],
|
||||
"metadata": payment["metadata"],
|
||||
},
|
||||
}
|
||||
|
||||
last_error = None
|
||||
for attempt in range(1, WEBHOOK_RETRY_COUNT + 1):
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
response = await client.post(WEBHOOK_URL, json=payload)
|
||||
response.raise_for_status()
|
||||
print(f"Webhook delivered: payment_id={payment['id']} attempt={attempt}")
|
||||
return
|
||||
except Exception as exc:
|
||||
last_error = exc
|
||||
print(f"Webhook attempt failed: payment_id={payment['id']} attempt={attempt} error={exc}")
|
||||
if attempt < WEBHOOK_RETRY_COUNT:
|
||||
await asyncio.sleep(WEBHOOK_RETRY_DELAY_SEC)
|
||||
|
||||
print(f"Webhook delivery failed: payment_id={payment['id']} error={last_error}")
|
||||
|
||||
|
||||
@app.post("/v3/payments")
|
||||
async def create_payment(
|
||||
request: Request,
|
||||
authorization: Optional[str] = Header(default=None),
|
||||
idempotence_key: Optional[str] = Header(default=None, alias="Idempotence-Key"),
|
||||
):
|
||||
require_basic_auth(authorization)
|
||||
|
||||
if not idempotence_key:
|
||||
raise HTTPException(status_code=400, detail="Idempotence-Key header is required")
|
||||
|
||||
if idempotence_key in idempotence_db:
|
||||
existing_payment_id = idempotence_db[idempotence_key]["payment_id"]
|
||||
payment = payments_db[existing_payment_id]
|
||||
return JSONResponse(content=build_payment_response(payment, request))
|
||||
|
||||
payload = await request.json()
|
||||
|
||||
amount_data = payload.get("amount") or {}
|
||||
confirmation_data = payload.get("confirmation") or {}
|
||||
metadata = payload.get("metadata") or {}
|
||||
|
||||
return_url = confirmation_data.get("return_url")
|
||||
if not return_url:
|
||||
raise HTTPException(status_code=400, detail="confirmation.return_url is required")
|
||||
|
||||
amount_value = parse_amount_value(amount_data.get("value"))
|
||||
currency = amount_data.get("currency", "RUB")
|
||||
capture = bool(payload.get("capture", False))
|
||||
description = str(payload.get("description") or "")[:128]
|
||||
|
||||
scenario = str(metadata.get("mock_scenario", "success")).strip().lower()
|
||||
if scenario not in {"success", "cancel", "fail"}:
|
||||
raise HTTPException(status_code=400, detail="metadata.mock_scenario must be success, cancel or fail")
|
||||
|
||||
payment_id = str(uuid4())
|
||||
payment = {
|
||||
"id": payment_id,
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
"created_at": utc_now_iso(),
|
||||
"description": description,
|
||||
"metadata": metadata,
|
||||
"capture": capture,
|
||||
"return_url": return_url,
|
||||
"scenario": scenario,
|
||||
"amount": {
|
||||
"value": amount_value,
|
||||
"currency": currency,
|
||||
},
|
||||
"recipient": {
|
||||
"account_id": MOCK_SHOP_ID or "100500",
|
||||
"gateway_id": "100700",
|
||||
},
|
||||
}
|
||||
payments_db[payment_id] = payment
|
||||
idempotence_db[idempotence_key] = {"payment_id": payment_id}
|
||||
|
||||
return JSONResponse(content=build_payment_response(payment, request))
|
||||
|
||||
|
||||
@app.get("/v3/payments/{payment_id}")
|
||||
async def get_payment(payment_id: str, request: Request, authorization: Optional[str] = Header(default=None)):
|
||||
require_basic_auth(authorization)
|
||||
|
||||
payment = payments_db.get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="payment not found")
|
||||
|
||||
return JSONResponse(content=build_payment_response(payment, request))
|
||||
|
||||
|
||||
@app.get("/checkout", response_class=HTMLResponse)
|
||||
async def checkout(payment_id: str):
|
||||
payment = payments_db.get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="payment not found")
|
||||
|
||||
html = CHECKOUT_TEMPLATE.render(
|
||||
payment_id=payment["id"],
|
||||
status=payment["status"],
|
||||
scenario=payment["scenario"],
|
||||
description=payment["description"] or "-",
|
||||
amount=payment["amount"]["value"],
|
||||
currency=payment["amount"]["currency"],
|
||||
)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@app.post("/process/{payment_id}")
|
||||
async def process_payment(payment_id: str, request: Request):
|
||||
payment = payments_db.get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="payment not found")
|
||||
|
||||
form = await request.form()
|
||||
action = str(form.get("action") or payment["scenario"]).strip().lower()
|
||||
|
||||
if action == "success":
|
||||
payment["status"] = "succeeded"
|
||||
payment["paid"] = True
|
||||
elif action in {"cancel", "fail"}:
|
||||
payment["status"] = "canceled"
|
||||
payment["paid"] = False
|
||||
else:
|
||||
raise HTTPException(status_code=400, detail="unsupported action")
|
||||
|
||||
await send_webhook(payment)
|
||||
return RedirectResponse(url=payment["return_url"], status_code=303)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {
|
||||
"status": "ok",
|
||||
"payments_total": len(payments_db),
|
||||
"idempotence_keys_total": len(idempotence_db),
|
||||
}
|
||||
177
main.py.bak
Normal file
177
main.py.bak
Normal file
@@ -0,0 +1,177 @@
|
||||
import os
|
||||
from uuid import uuid4
|
||||
from typing import Dict, Any
|
||||
|
||||
import httpx
|
||||
from fastapi import FastAPI, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse
|
||||
from jinja2 import Template
|
||||
|
||||
app = FastAPI(title="YooKassa Mock")
|
||||
|
||||
payments_db: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
WEBHOOK_URL = os.getenv("WEBHOOK_URL", "").strip()
|
||||
MOCK_HOST = os.getenv("MOCK_HOST", "").strip()
|
||||
MOCK_PORT = int(os.getenv("MOCK_PORT", "8081"))
|
||||
|
||||
CHECKOUT_TEMPLATE = Template("""
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Оплата заказа</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background: #f6f7fb;
|
||||
margin: 0;
|
||||
padding: 40px 20px;
|
||||
color: #222;
|
||||
}
|
||||
.card {
|
||||
max-width: 480px;
|
||||
margin: 0 auto;
|
||||
background: #fff;
|
||||
border-radius: 12px;
|
||||
padding: 32px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.08);
|
||||
}
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 24px;
|
||||
}
|
||||
.amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
margin: 16px 0 24px;
|
||||
}
|
||||
button {
|
||||
width: 100%;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 14px 18px;
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
cursor: pointer;
|
||||
background: #006efc;
|
||||
color: white;
|
||||
}
|
||||
.meta {
|
||||
margin-top: 20px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
word-break: break-all;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<h1>Тестовая оплата</h1>
|
||||
<div>Сумма к оплате:</div>
|
||||
<div class="amount">{{ amount }} {{ currency }}</div>
|
||||
|
||||
<form method="post" action="/process/{{ payment_id }}">
|
||||
<button type="submit">Оплатить</button>
|
||||
</form>
|
||||
|
||||
<div class="meta">
|
||||
payment_id: {{ payment_id }}
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
|
||||
def build_base_url(request: Request) -> str:
|
||||
if MOCK_HOST:
|
||||
return f"http://{MOCK_HOST}:{MOCK_PORT}"
|
||||
return str(request.base_url).rstrip("/")
|
||||
|
||||
|
||||
@app.post("/v3/payments")
|
||||
async def create_payment(request: Request):
|
||||
payload = await request.json()
|
||||
|
||||
payment_id = str(uuid4())
|
||||
|
||||
amount_data = payload.get("amount") or {}
|
||||
confirmation_data = payload.get("confirmation") or {}
|
||||
|
||||
value = amount_data.get("value", "0.00")
|
||||
currency = amount_data.get("currency", "RUB")
|
||||
return_url = confirmation_data.get("return_url")
|
||||
|
||||
if not return_url:
|
||||
raise HTTPException(status_code=400, detail="confirmation.return_url is required")
|
||||
|
||||
payments_db[payment_id] = {
|
||||
"return_url": return_url,
|
||||
"amount": value,
|
||||
"currency": currency,
|
||||
}
|
||||
|
||||
base_url = build_base_url(request)
|
||||
confirmation_url = f"{base_url}/checkout?payment_id={payment_id}"
|
||||
|
||||
response = {
|
||||
"id": payment_id,
|
||||
"status": "pending",
|
||||
"paid": False,
|
||||
"amount": {
|
||||
"value": value,
|
||||
"currency": currency,
|
||||
},
|
||||
"confirmation": {
|
||||
"type": "redirect",
|
||||
"confirmation_url": confirmation_url,
|
||||
},
|
||||
"test": True,
|
||||
}
|
||||
|
||||
return JSONResponse(content=response)
|
||||
|
||||
|
||||
@app.get("/checkout", response_class=HTMLResponse)
|
||||
async def checkout(payment_id: str):
|
||||
payment = payments_db.get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="payment not found")
|
||||
|
||||
html = CHECKOUT_TEMPLATE.render(
|
||||
payment_id=payment_id,
|
||||
amount=payment["amount"],
|
||||
currency=payment["currency"],
|
||||
)
|
||||
return HTMLResponse(content=html)
|
||||
|
||||
|
||||
@app.post("/process/{payment_id}")
|
||||
async def process_payment(payment_id: str):
|
||||
payment = payments_db.get(payment_id)
|
||||
if not payment:
|
||||
raise HTTPException(status_code=404, detail="payment not found")
|
||||
|
||||
if WEBHOOK_URL:
|
||||
webhook_payload = {
|
||||
"event": "payment.succeeded",
|
||||
"type": "notification",
|
||||
"object": {
|
||||
"id": payment_id,
|
||||
"status": "succeeded",
|
||||
},
|
||||
}
|
||||
try:
|
||||
async with httpx.AsyncClient(timeout=10.0) as client:
|
||||
await client.post(WEBHOOK_URL, json=webhook_payload)
|
||||
except Exception as e:
|
||||
print(f"Webhook send failed: {e}")
|
||||
|
||||
return RedirectResponse(url=payment["return_url"], status_code=303)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
return {"status": "ok"}
|
||||
5
requirements.txt
Normal file
5
requirements.txt
Normal file
@@ -0,0 +1,5 @@
|
||||
fastapi==0.115.8
|
||||
uvicorn[standard]==0.34.0
|
||||
httpx==0.28.1
|
||||
jinja2==3.1.5
|
||||
python-multipart==0.0.20
|
||||
Reference in New Issue
Block a user