Добавлена обработка уведомлений через ручку
This commit is contained in:
+3
-2
@@ -36,9 +36,10 @@
|
||||
| 027 | DONE | 2026-04-11 | Add TBank payment adapter, init_payment endpoint and rename order flow | `spec/tasks/027_add_tbank_payment_adapter_and_order_payment_link.md` |
|
||||
| 028 | DONE | 2026-04-12 | Add PostgreSQL adapter, order repository and persist order after payment link creation | `spec/tasks/028_add_postgresql_order_persistence.md` |
|
||||
| 029 | DONE | 2026-04-18 | Add TBank payment notification and success URLs | `spec/tasks/029_add_tbank_payment_urls.md` |
|
||||
| 030 | TODO | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **30**
|
||||
- TODO: **0**
|
||||
- Total: **31**
|
||||
- TODO: **1**
|
||||
- DONE: **30**
|
||||
|
||||
+37
-2
@@ -21,6 +21,9 @@
|
||||
- Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
|
||||
- Принимать сумму оплаты в поле `price` в копейках
|
||||
- После успешного получения ссылки на оплату от TBank сохранять все данные заявки вместе со ссылкой на оплату в PostgreSQL; ошибка сохранения не блокирует возврат ссылки клиенту
|
||||
- Принимать HTTP-уведомления TBank о статусах платежа на отдельный webhook endpoint
|
||||
- При получении валидного уведомления TBank со статусом `CONFIRMED` находить сохранённую заявку в PostgreSQL и регистрировать заказ в CDEK
|
||||
- Остальные валидные статусы платежа TBank подтверждать без регистрации заказа в CDEK
|
||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
||||
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
||||
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
||||
@@ -44,7 +47,7 @@
|
||||
- Cache TTL: 15 минут
|
||||
- **TBank** — https://securepay.tinkoff.ru/v2/Init
|
||||
- Аутентификация: `TerminalKey` и token на основе password
|
||||
- Операции: инициализация платежа и получение payment URL
|
||||
- Операции: инициализация платежа, получение payment URL, проверка подписи HTTP-уведомлений
|
||||
|
||||
### Фаза 2+
|
||||
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
||||
@@ -57,9 +60,10 @@
|
||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
||||
- `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
||||
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
|
||||
- Парсинг и валидация входных данных через Pydantic
|
||||
- Маппинг исключений сервиса в HTTP-ответы
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.init_payment()`
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()`, `AggregatorService.init_payment()` или `AggregatorService.handle_tbank_payment_notification()`
|
||||
|
||||
### Service (`app/services/aggregator.py`)
|
||||
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
||||
@@ -71,6 +75,10 @@
|
||||
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
||||
- `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
|
||||
- `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency, затем сохраняет данные заявки вместе с `payment_url` через injected order repository; ошибка сохранения логируется, но не блокирует возврат `payment_url`
|
||||
- `AggregatorService.handle_tbank_payment_notification(notification: TBankPaymentNotification) -> str`
|
||||
- `AggregatorService.handle_tbank_payment_notification()` оркестрирует проверку подписи уведомления через injected TBank adapter, определение действия по статусу через Business Logic, чтение сохранённой заявки через injected order repository и регистрацию заказа через injected CDEK adapter при статусе `CONFIRMED`
|
||||
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` не должно повторно создавать заказ в CDEK
|
||||
- Валидные уведомления TBank со статусами, отличными от `CONFIRMED`, подтверждаются ответом `OK` без обращения к CDEK
|
||||
- Service не содержит бизнес-логики и provider HTTP-деталей
|
||||
|
||||
### Business Logic (`app/domain/`)
|
||||
@@ -79,6 +87,7 @@
|
||||
- Логика сравнения цен
|
||||
- Правила применения конфигурируемого мультипликатора к ценам провайдеров
|
||||
- Округление цены после применения мультипликатора до целого значения по детерминированному правилу
|
||||
- Правило выбора действия для TBank payment notification по статусу платежа
|
||||
- Нормализация входных данных (например, идентификаторов городов и правил округления веса)
|
||||
- Чистые функции, без IO, без зависимостей от фреймворков
|
||||
|
||||
@@ -90,6 +99,9 @@
|
||||
### Repository (`app/repositories/order/`)
|
||||
- `OrderRepository` — сохранение данных заявки в PostgreSQL
|
||||
- Операции: `create_order(session, order_data)` — сохраняет запись заявки с данными `InitPaymentRequest` и `payment_url`
|
||||
- Операции: `get_order_by_order_uuid(session, order_uuid)` — получает сохранённую заявку для payment notification flow
|
||||
- Операции: `mark_payment_status(session, order_uuid, status, payment_id)` — сохраняет последний статус платежа TBank
|
||||
- Операции: `mark_cdek_order_registered(session, order_uuid, cdek_order_uuid)` — сохраняет UUID заказа CDEK после успешной регистрации
|
||||
- SQLAlchemy ORM model таблицы `orders` в `models.py`
|
||||
- Без бизнес-решений; только CRUD-примитивы
|
||||
|
||||
@@ -113,6 +125,7 @@
|
||||
- `base.py` — исключения TBank payment adapter
|
||||
- `client.py` — `TBankAdapter`
|
||||
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
|
||||
- `TBankAdapter.verify_payment_notification(notification: TBankPaymentNotification) -> None` проверяет token HTTP-уведомления TBank по top-level scalar fields, исключая `Token` и вложенные объекты
|
||||
- TBank adapter инкапсулирует HTTP-взаимодействие с TBank Init API, auth token, retries, timeout, serialization и error handling
|
||||
- TBank adapter владеет собственной секцией конфигурации `tbank_payment` с полями `init_url`, `auth.terminal_key`, `auth.password`, `timeout_seconds`, `retry_attempts`, `retry_backoff_seconds`
|
||||
|
||||
@@ -226,6 +239,28 @@ packages: list[{number: str, weight: int, length: int, width: int, height: int,
|
||||
payment_url: str
|
||||
```
|
||||
|
||||
### Входная: `TBankPaymentNotification`
|
||||
```
|
||||
TerminalKey: str
|
||||
OrderId: str
|
||||
Success: bool
|
||||
Status: str
|
||||
PaymentId: int
|
||||
ErrorCode: str
|
||||
Amount: int
|
||||
Token: str
|
||||
```
|
||||
|
||||
`PaymentId` приходит из TBank как JSON-число (long integer) и валидируется как положительное целое.
|
||||
Модель уведомления должна допускать дополнительные top-level поля TBank. Проверка token использует все top-level scalar fields кроме `Token`, добавляет `Password` из `tbank_payment.auth.password`, сортирует ключи по алфавиту, конкатенирует строковые представления значений (булевы — как lowercase `true`/`false`) и сравнивает SHA-256 hash с `Token`. Вложенные объекты, включая `Data` и `Receipt`, не участвуют в проверке token.
|
||||
|
||||
### Выходная: TBank notification response
|
||||
```
|
||||
OK
|
||||
```
|
||||
|
||||
Успешно обработанное HTTP-уведомление TBank возвращает `HTTP 200` и plain text body `OK`.
|
||||
|
||||
---
|
||||
|
||||
## Технологический стек
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
---
|
||||
id: 030
|
||||
title: Add TBank payment notification webhook and CDEK order creation
|
||||
status: TODO
|
||||
created: 2026-04-18
|
||||
---
|
||||
|
||||
## Context
|
||||
TBank Init API уже получает `NotificationURL`, а данные заявки сохраняются в PostgreSQL после создания payment link. Сейчас приложение не принимает HTTP-уведомления TBank и не создаёт заказ в CDEK после подтверждения оплаты.
|
||||
|
||||
## Goal
|
||||
Добавить `POST /api/v1/delivery/tbank/notifications`, который принимает payment notification от TBank, проверяет token уведомления, при валидном статусе `CONFIRMED` находит сохранённую заявку по `OrderId` и регистрирует заказ в CDEK. Валидные уведомления с другими статусами должны подтверждаться без регистрации заказа в CDEK.
|
||||
|
||||
## Constraints
|
||||
- Controller отвечает только за routing, DTO validation и HTTP error mapping; endpoint вызывает ровно один метод Service: `AggregatorService.handle_tbank_payment_notification()`.
|
||||
- Service выполняет только orchestration: verification через TBank adapter, выбор действия через pure Business Logic, чтение/обновление заявки через OrderRepository и вызов injected CDEK order adapter.
|
||||
- Правило `CONFIRMED` -> создать заказ CDEK, остальные статусы -> подтвердить без CDEK должно быть pure Business Logic в `app/domain/`.
|
||||
- TBank-specific token verification должна быть инкапсулирована в `app/adapters/tbank/` и использовать `tbank_payment.auth.password`.
|
||||
- Проверка token MUST следовать контракту TBank HTTP-уведомлений: использовать top-level scalar fields кроме `Token`, не включать вложенные объекты (`Data`, `Receipt`), добавить `Password`, отсортировать ключи по алфавиту, конкатенировать значения, посчитать SHA-256 и сравнить с `Token`.
|
||||
- Успешно обработанное уведомление MUST возвращать `HTTP 200` с plain text body `OK`.
|
||||
- Валидные уведомления со статусами, отличными от `CONFIRMED`, MUST возвращать `OK` без чтения CDEK и без создания заказа.
|
||||
- Повторное валидное уведомление `CONFIRMED` для заявки с уже сохранённым `cdek_order_uuid` MUST возвращать `OK` без повторного вызова CDEK.
|
||||
- Если token невалиден, endpoint MUST возвращать deterministic `400` и не обращаться к repository или CDEK.
|
||||
- Если заявка для `OrderId` не найдена или CDEK registration не завершилась успешно, endpoint MUST не возвращать `OK`, чтобы TBank мог повторить notification delivery.
|
||||
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions.
|
||||
- CDEK order payload MUST передавать `order_uuid` как external идентификатор заказа CDEK (поле `number` в CDEK order contract), чтобы повторные вызовы CDEK при HTTP timeout/ретрае обрабатывались идемпотентно на стороне CDEK и никогда не создавали дубль заказа.
|
||||
- Service MUST трактовать ответ CDEK "заказ с таким external id уже существует" (возврат существующего `entity.uuid` либо CDEK-specific duplicate response) как успех, сохранять возвращённый `cdek_order_uuid` и отвечать `OK`, а не как ошибку с повторной регистрацией.
|
||||
- Не изменять public contract `POST /api/v1/delivery/init-payment`, price flow и address suggestion flow.
|
||||
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
|
||||
- Не изменять файлы в `spec/`.
|
||||
|
||||
## Acceptance criteria
|
||||
- Существует schema `TBankPaymentNotification` с обязательными полями `TerminalKey`, `OrderId`, `Success`, `Status`, `PaymentId`, `ErrorCode`, `Amount`, `Token` и поддержкой дополнительных top-level полей TBank. `PaymentId` валидируется как положительное целое (TBank присылает long integer) и сохраняется в `tbank_payment_id` колонке `BIGINT`.
|
||||
- `POST /api/v1/delivery/tbank/notifications` реализован в существующем controller `app/controllers/v1/delivery.py` и возвращает `PlainTextResponse("OK")` при успешной обработке.
|
||||
- Controller делегирует обработку ровно в `AggregatorService.handle_tbank_payment_notification()` и не содержит branching по TBank status.
|
||||
- `TBankAdapter` умеет проверять token payment notification через `tbank_payment.auth.password`; невалидный token маппится в service/controller error path без repository/CDEK side effects.
|
||||
- В `app/domain/` есть pure function, которая для `Status == "CONFIRMED"`, `Success == true` и `ErrorCode == "0"` возвращает действие регистрации CDEK, а для остальных статусов возвращает действие acknowledge-only.
|
||||
- `OrderRepository` предоставляет primitive для получения заявки по `order_uuid`, сохранения последнего TBank payment status/payment id и сохранения `cdek_order_uuid`.
|
||||
- Таблица `orders` содержит минимум поля `payment_status`, `tbank_payment_id` (BIGINT), `cdek_order_uuid`, `updated_at` в исходной миграции; отдельная миграция не вводится, так как production БД ещё не развёрнута.
|
||||
- При валидном `CONFIRMED` notification service получает order по `OrderId`, реконструирует `InitPaymentRequest` из сохранённых данных, вызывает injected CDEK order adapter registration и сохраняет полученный `cdek_order_uuid`.
|
||||
- Если для `OrderId` уже сохранён `cdek_order_uuid`, повторный `CONFIRMED` notification возвращает `OK` без повторного вызова CDEK.
|
||||
- CDEK order mapper проставляет `InitPaymentRequest.order_uuid` в поле external номера заказа CDEK (`number`), так что повторный POST с тем же external id не создаёт второй заказ в CDEK.
|
||||
- Если CDEK create фактически завершился успешно, но сохранение `cdek_order_uuid` в DB не прошло, следующий валидный `CONFIRMED` notification MUST завершиться сохранением того же `cdek_order_uuid` (полученного по тому же external id) без создания второго заказа в CDEK.
|
||||
- Валидные notification со статусами `AUTHORIZED`, `REJECTED`, `CANCELED`, `DEADLINE_EXPIRED` и неизвестными status values возвращают `OK` без регистрации CDEK order.
|
||||
- Ошибка поиска заявки или ошибка CDEK registration возвращает deterministic non-OK HTTP response и логируется.
|
||||
- `NotificationURL` в runtime/test/example конфигурации, если он присутствует в репозитории, указывает на `/api/v1/delivery/tbank/notifications`.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Добавлена schema `TBankPaymentNotification`.
|
||||
- [ ] Добавлена pure Business Logic для выбора действия по TBank payment notification status.
|
||||
- [ ] Добавлена token verification logic в TBank adapter.
|
||||
- [ ] Расширена SQLAlchemy model и обновлена существующая Alembic migration `20260412_028_create_orders_table` payment/CDEK status fields (production БД отсутствует, новая миграция не нужна).
|
||||
- [ ] Расширен `OrderRepository` primitives для read/update операций, нужных webhook flow.
|
||||
- [ ] Реализован `AggregatorService.handle_tbank_payment_notification()` с DI dependencies.
|
||||
- [ ] Добавлен endpoint `POST /api/v1/delivery/tbank/notifications` в существующий delivery controller.
|
||||
- [ ] Wiring использует существующие `TBankAdapter`, `OrderRepository` и CDEK provider/adapter без provider HTTP details в Service/Controller.
|
||||
- [ ] Повторные `CONFIRMED` notification обрабатываются идемпотентно.
|
||||
- [ ] CDEK order mapper проставляет `order_uuid` в поле external номера заказа CDEK.
|
||||
- [ ] Service корректно обрабатывает ответ CDEK "заказ уже существует" как успех и сохраняет возвращённый `cdek_order_uuid`.
|
||||
- [ ] Валидные non-`CONFIRMED` statuses подтверждаются без CDEK side effects.
|
||||
- [ ] Обновлены tests для domain, adapter, repository, service, controller и smoke wiring.
|
||||
- [ ] Все команды из раздела Commands проходят.
|
||||
|
||||
## Tests
|
||||
- Добавить `tests/domain/test_payment_notifications.py`: `CONFIRMED` + `Success=true` + `ErrorCode=0` -> create CDEK order action; остальные known/unknown statuses -> acknowledge-only.
|
||||
- Обновить или добавить `tests/adapters/tbank/test_notifications.py`: valid token, invalid token, exclusion of `Token`, exclusion of nested `Data`/`Receipt`, inclusion of extra scalar fields, deterministic SHA-256 comparison.
|
||||
- Обновить `tests/repositories/order/test_repository.py`: получение заявки по `order_uuid`, сохранение `payment_status`/`tbank_payment_id`, сохранение `cdek_order_uuid`, duplicate/idempotency scenario.
|
||||
- Обновить `tests/adapters/delivery_providers/cdek/test_order_mapper.py`: CDEK order payload содержит `InitPaymentRequest.order_uuid` в поле external номера заказа (`number`).
|
||||
- Добавить `tests/services/test_tbank_notifications.py`: valid `CONFIRMED` вызывает repository lookup, CDEK registration и save `cdek_order_uuid`; duplicate `CONFIRMED` не вызывает CDEK; non-`CONFIRMED` возвращает `OK` без CDEK; invalid token не обращается к repository; missing order/CDEK failure возвращает service error; сценарий "CDEK create succeeded, save `cdek_order_uuid` raised" — следующий `CONFIRMED` notification вызывает CDEK повторно с тем же external id, получает тот же `cdek_order_uuid`, сохраняет его и возвращает `OK` (суммарно ровно один реальный заказ в CDEK).
|
||||
- Добавить `tests/controllers/v1/test_tbank_notifications.py`: endpoint возвращает plain text `OK` для successful service response, 400 для invalid token, 503 для temporary processing failure, controller делегирует ровно один service method.
|
||||
- Обновить `tests/smoke/test_app_import.py` при необходимости для проверки wiring нового endpoint.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest tests/domain/test_payment_notifications.py -q`
|
||||
- `poetry run pytest tests/adapters/tbank/test_notifications.py -q`
|
||||
- `poetry run pytest tests/repositories/order/test_repository.py -q`
|
||||
- `poetry run pytest tests/services/test_tbank_notifications.py -q`
|
||||
- `poetry run pytest tests/controllers/v1/test_tbank_notifications.py -q`
|
||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
||||
- `poetry run pytest -q`
|
||||
- `python3 spec/gen_spec_index.py --check`
|
||||
Reference in New Issue
Block a user