Добавлен адаптер к tbank и формирование ссылки на оплату
This commit is contained in:
+3
-2
@@ -33,9 +33,10 @@
|
||||
| 024 | DONE | 2026-03-29 | Add TomTom address suggestion adapter and Europe routing | `spec/tasks/024_add_tomtom_address_suggestion_adapter.md` |
|
||||
| 025 | DONE | 2026-04-03 | Remove company from Create Delivery Order parties | `spec/tasks/025_remove_company_from_create_delivery_order.md` |
|
||||
| 026 | DONE | 2026-04-05 | Align CDEK order contract with single phone and kilogram package weight | `spec/tasks/026_align_cdek_order_contract_single_phone_and_weight_units.md` |
|
||||
| 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` |
|
||||
|
||||
## Summary
|
||||
|
||||
- Total: **27**
|
||||
- Total: **28**
|
||||
- TODO: **0**
|
||||
- DONE: **27**
|
||||
- DONE: **28**
|
||||
|
||||
+33
-20
@@ -17,8 +17,9 @@
|
||||
|
||||
- Принимать запрос на расчёт стоимости доставки (идентификаторы городов отправления/назначения, вес, габариты)
|
||||
- Поддерживать необязательный параметр `parcel_type` в запросе расчёта стоимости доставки для фильтрации тарифов по типу отправления
|
||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед созданием заказа
|
||||
- Принимать запрос на создание заказа CDEK по контракту из `http-client.http` для сценария "доставка, до двери"
|
||||
- Предоставлять отдельный endpoint подсказок адреса, чтобы frontend мог получить точное значение для `from_location.address` и `to_location.address` перед инициализацией оплаты доставки
|
||||
- Принимать запрос на инициализацию оплаты доставки через TBank и возвращать ссылку на оплату без регистрации заказа в CDEK
|
||||
- Принимать сумму оплаты в поле `price` в копейках
|
||||
- Выбирать сервис подсказок адреса по `country_code` через маппинг стран в конфиге
|
||||
- Для `RU`, `BY` и `KZ`, сопоставленных с provider id `dadata`, использовать `dadata.ru`
|
||||
- Для `AM`, `AZ`, `KG`, `MD`, `TJ`, `TM` и `UZ`, сопоставленных с provider id `yandex_geosuggest`, использовать Yandex Geosuggest
|
||||
@@ -38,8 +39,11 @@
|
||||
### Фаза 1
|
||||
- **CDEK** — https://apidoc.cdek.ru/
|
||||
- Аутентификация: OAuth2 (client credentials)
|
||||
- Операции: расчёт тарифа, регистрация заказа
|
||||
- Операции: расчёт тарифа
|
||||
- Cache TTL: 15 минут
|
||||
- **TBank** — https://securepay.tinkoff.ru/v2/Init
|
||||
- Аутентификация: `TerminalKey` и token на основе password
|
||||
- Операции: инициализация платежа и получение payment URL
|
||||
|
||||
### Фаза 2+
|
||||
- Дополнительные провайдеры (Boxberry, DHL и др.) — подключаются через интерфейс `DeliveryProvider` без изменений в логике агрегации
|
||||
@@ -51,10 +55,10 @@
|
||||
### Controller (`app/controllers/v1/delivery.py`)
|
||||
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
|
||||
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
|
||||
- `POST /api/v1/delivery/order` — принимает `OrderCreateRequest`, возвращает `OrderCreateResponse`
|
||||
- `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
|
||||
- Парсинг и валидация входных данных через Pydantic
|
||||
- Маппинг исключений сервиса в HTTP-ответы
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.create_order()`
|
||||
- Каждый endpoint вызывает ровно один метод Service: `AggregatorService.get_all_prices()`, `AggregatorService.suggest_addresses()` или `AggregatorService.init_payment()`
|
||||
|
||||
### Service (`app/services/aggregator.py`)
|
||||
- `AggregatorService.get_all_prices(request: DeliveryCalculationRequest) -> list[DeliveryPrice]`
|
||||
@@ -64,8 +68,8 @@
|
||||
- Сортирует тарифы по цене
|
||||
- `AggregatorService.suggest_addresses(request: AddressSuggestRequest) -> list[AddressSuggestion]`
|
||||
- `AggregatorService.suggest_addresses()` выбирает address suggestion provider по `country_code` через injected config mapping и оркестрирует ровно один adapter call
|
||||
- `AggregatorService.create_order(request: OrderCreateRequest) -> OrderCreateResponse`
|
||||
- `AggregatorService.create_order()` оркестрирует регистрацию заказа в CDEK через injected adapter dependency
|
||||
- `AggregatorService.init_payment(request: InitPaymentRequest) -> InitPaymentResponse`
|
||||
- `AggregatorService.init_payment()` оркестрирует инициализацию платежа через injected TBank adapter dependency
|
||||
- Service не содержит бизнес-логики и provider HTTP-деталей
|
||||
|
||||
### Business Logic (`app/domain/`)
|
||||
@@ -92,14 +96,18 @@
|
||||
- `cdek/client.py` — HTTP-клиент (httpx AsyncClient), аутентификация, ретраи, таймаут (10с)
|
||||
- `cdek/auth.py` — управление OAuth2-токеном
|
||||
- `cdek/mapper.py` — ответ CDEK → `list[DeliveryPrice]`
|
||||
- `cdek/order_mapper.py` — request/response mapping для регистрации заказа CDEK
|
||||
- `cdek/order_mapper.py` — request/response mapping для CDEK order contract; публичный `init-payment` flow не вызывает регистрацию заказа в CDEK
|
||||
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
|
||||
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
|
||||
|
||||
Для сценария создания заказа CDEK adapter принимает валидированную order model, отправляет контракт `Регистрация заказа (тип "доставка", до двери)` из `http-client.http` и возвращает внутреннюю response model без утечки HTTP-деталей в Service.
|
||||
Во внутреннем order flow `sender` и `recipient` содержат ровно одно поле `phone`, а CDEK adapter сериализует его в provider payload `phones` с одним элементом.
|
||||
Поле `services` в order flow является необязательным; при отсутствии значения adapter не отправляет `services` в CDEK payload.
|
||||
Поле `packages[*].weight` во входном order request задаётся в килограммах, а CDEK adapter конвертирует его в граммы перед отправкой в provider API.
|
||||
Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой.
|
||||
|
||||
### Adapter (`app/adapters/tbank`)
|
||||
- `base.py` — исключения TBank payment adapter
|
||||
- `client.py` — `TBankAdapter`
|
||||
- `TBankAdapter.create_payment_link(order_uuid: str, amount_kopecks: int) -> str` инициализирует платёж TBank и возвращает payment URL
|
||||
- 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`
|
||||
|
||||
### Adapter (`app/adapters/address_suggestions`)
|
||||
- `base.py` — абстрактный интерфейс `AddressSuggestionProvider`:
|
||||
@@ -168,8 +176,10 @@ flat: str | None
|
||||
postal_code: str | None
|
||||
```
|
||||
|
||||
### Входная: `OrderCreateRequest`
|
||||
### Входная: `InitPaymentRequest`
|
||||
```
|
||||
order_uuid: str
|
||||
price: int
|
||||
type: Literal[2]
|
||||
tariff_code: Literal[535]
|
||||
comment: str | None
|
||||
@@ -193,14 +203,14 @@ services: list[{code: str, parameter: str}] | None
|
||||
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}]
|
||||
```
|
||||
|
||||
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; order flow не выполняет address suggestion lookup.
|
||||
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны заказа; передача нескольких телефонов во входном API не поддерживается.
|
||||
`packages[*].weight` в `OrderCreateRequest` задаётся в килограммах, а в payload CDEK должен передаваться в граммах.
|
||||
`price` задаётся в копейках, является обязательным целым числом и должен быть больше 0.
|
||||
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup.
|
||||
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается.
|
||||
`packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload.
|
||||
|
||||
### Выходная: `OrderCreateResponse`
|
||||
### Выходная: `InitPaymentResponse`
|
||||
```
|
||||
provider: str
|
||||
order_uuid: str
|
||||
payment_url: str
|
||||
```
|
||||
|
||||
---
|
||||
@@ -246,6 +256,9 @@ app/
|
||||
│ ├── auth.py
|
||||
│ ├── mapper.py
|
||||
│ └── order_mapper.py
|
||||
│ └── tbank/
|
||||
│ ├── base.py
|
||||
│ └── client.py
|
||||
├── repositories/
|
||||
│ └── cache/
|
||||
│ └── redis_cache.py # Repository
|
||||
@@ -253,7 +266,7 @@ app/
|
||||
│ ├── address.py
|
||||
│ ├── request.py
|
||||
│ ├── response.py
|
||||
│ └── order.py
|
||||
│ └── payment.py
|
||||
└── config.py
|
||||
```
|
||||
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
---
|
||||
id: 027
|
||||
title: Add TBank payment adapter, init_payment endpoint and rename order flow
|
||||
status: DONE
|
||||
created: 2026-04-11
|
||||
---
|
||||
|
||||
## Context
|
||||
В проекте ещё нет платёжного адаптера, каталог `app/adapters/` содержит только `delivery_providers/` и `address_suggestions/`. Текущий endpoint `POST /api/v1/delivery/order` регистрирует заказ в CDEK. В новом флоу endpoint будет инициировать оплату через TBank и возвращать ссылку на оплату, без регистрации заказа в CDEK. В связи с изменением бизнес-смысла необходимо переименовать endpoint, сервисный метод, все DTO и модели флоу.
|
||||
|
||||
## Goal
|
||||
1. Добавить новый payment adapter `TBankAdapter` для генерации ссылки на оплату TBank.
|
||||
2. Переименовать endpoint `POST /api/v1/delivery/order` → `POST /api/v1/delivery/init-payment`.
|
||||
3. Переименовать сервисный метод `AggregatorService.create_order()` → `AggregatorService.init_payment()`.
|
||||
4. Переименовать все DTO и модели флоу: `OrderCreateRequest` → `InitPaymentRequest`, `OrderCreateResponse` → `InitPaymentResponse`, `InvalidOrderCreateRequestError` → `InvalidInitPaymentRequestError`, `OrderCreationUnavailableError` → `InitPaymentUnavailableError`.
|
||||
5. Переименовать файл схем `app/schemas/order.py` → `app/schemas/payment.py`.
|
||||
6. Добавить в `InitPaymentRequest` обязательное поле `price: int` (в копейках) и вызвать `payment_adapter.create_payment_link()` из `init_payment()`, вернув `payment_url` в `InitPaymentResponse`.
|
||||
|
||||
## Constraints
|
||||
- Новый payment adapter должен располагаться в отдельном каталоге `app/adapters/tbank/` с модулями `base.py` и `client.py`; не размещать payment logic внутри `delivery_providers/` или `address_suggestions/`.
|
||||
- Payment adapter MUST инкапсулировать HTTP взаимодействие с TBank API, auth, retries, serialization и error handling; наружу должен экспонироваться только service-facing method (`create_payment_link(order_uuid: str, amount_kopecks: int) -> str`), без утечки HTTP деталей в Service.
|
||||
- Payment adapter MUST владеть собственной секцией конфигурации (`TBankPaymentConfig`) с обязательными полями auth и URL; конфигурация читается из `config.yaml`, который остаётся в `.gitignore`.
|
||||
- `InitPaymentRequest` MUST содержать обязательное поле `price: int` в копейках с валидацией `gt=0`; единица измерения явно зафиксирована в schema и в `spec/overview.md`.
|
||||
- `AggregatorService.init_payment()` MUST вызывать `payment_adapter.create_payment_link()`. Payment adapter передаётся в service через dependency injection (новый аргумент конструктора).
|
||||
- Ошибки payment adapter MUST маппиться в `InvalidInitPaymentRequestError` (→ 400) и `InitPaymentUnavailableError` (→ 503) на уровне controller.
|
||||
- Controller `POST /api/v1/delivery/init-payment` MUST вызывать ровно один метод Service (`AggregatorService.init_payment()`); старый endpoint `/order` удаляется, новые дополнительные endpoints запрещены.
|
||||
- Service слой не содержит HTTP, retries или TBank-специфичных деталей.
|
||||
- Scope задачи не включает: повторные попытки оплаты, refund flow, webhook обработку платёжных уведомлений, persistence заказов, изменения price flow, address suggestion flow, а также поддержку других платёжных провайдеров.
|
||||
- Не изменять файлы в `spec/` кроме создания этой задачи и обновления `spec/overview.md` при необходимости (обновление выполняется Planner).
|
||||
|
||||
## Acceptance criteria
|
||||
- В `app/adapters/tbank/` существуют `base.py` с исключениями адаптера и `client.py` с реализацией `TBankAdapter`.
|
||||
- `TBankAdapter` реализует async метод `create_payment_link(order_uuid: str, amount_kopecks: int) -> str`, принимает идентификатор заказа и сумму в копейках и возвращает URL.
|
||||
- `TBankAdapter` имеет собственную конфигурацию `TBankPaymentConfig` с обязательными полями auth и URL; конфигурация читается из yaml.
|
||||
- Файл `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты обновлены.
|
||||
- `InitPaymentRequest` (бывший `OrderCreateRequest`) содержит обязательное поле `price: int` (в копейках) с валидацией `gt=0`; запрос без `price` или с нецелым/отрицательным значением возвращает 422.
|
||||
- `InitPaymentResponse` (бывший `OrderCreateResponse`) содержит поле `payment_url: str` (минимум 1 символ) и возвращается из endpoint `POST /api/v1/delivery/init-payment`.
|
||||
- `AggregatorService.init_payment()` (бывший `create_order()`) принимает `InitPaymentRequest`, вызывает `payment_adapter.create_payment_link()` с `order_uuid` и `price`, и возвращает `InitPaymentResponse` с `payment_url`.
|
||||
- Сервисные исключения переименованы: `InvalidInitPaymentRequestError` (бывший `InvalidOrderCreateRequestError`), `InitPaymentUnavailableError` (бывший `OrderCreationUnavailableError`).
|
||||
- Endpoint `POST /api/v1/delivery/order` удалён; endpoint `POST /api/v1/delivery/init-payment` возвращает `InitPaymentResponse`.
|
||||
- Ошибки TBank provider request мапятся в 400, transport/недоступность — в 503 на уровне controller.
|
||||
- Controller продолжает вызывать ровно один метод Service.
|
||||
- Пример запроса в `http-client.http` обновлён: использует `/init-payment`, содержит поле `price` в копейках и отражает обновлённый response contract.
|
||||
- `config.yaml` пример (в `.gitignore`) содержит секцию с параметрами TBank adapter.
|
||||
|
||||
## Definition of Done
|
||||
- [ ] Создан модуль `app/adapters/tbank/` с `base.py` и `client.py`, реализующий `TBankAdapter`.
|
||||
- [ ] Добавлена конфигурация TBank adapter (`TBankPaymentConfig`) в `app/config.py` и соответствующий пример в `config.yaml`.
|
||||
- [ ] `app/schemas/order.py` переименован в `app/schemas/payment.py`; все импорты в контроллере, сервисе и тестах обновлены.
|
||||
- [ ] `OrderCreateRequest` → `InitPaymentRequest`, `OrderCreateResponse` → `InitPaymentResponse` во всех файлах.
|
||||
- [ ] `InvalidOrderCreateRequestError` → `InvalidInitPaymentRequestError`, `OrderCreationUnavailableError` → `InitPaymentUnavailableError` во всех файлах.
|
||||
- [ ] `AggregatorService.create_order()` → `AggregatorService.init_payment()`; метод вызывает TBank adapter и возвращает `InitPaymentResponse`.
|
||||
- [ ] Payment adapter injected в service через wiring в `app/controllers/v1/delivery.py::_build_aggregator_service`.
|
||||
- [ ] Endpoint `/order` удалён, добавлен `/init-payment`; controller function переименована в `init_payment`.
|
||||
- [ ] Controller маппит `InvalidInitPaymentRequestError` → 400, `InitPaymentUnavailableError` → 503.
|
||||
- [ ] Обновлены unit tests для schemas, service, controller, а также добавлены unit tests для TBank adapter (HTTP client с stub transport).
|
||||
- [ ] Обновлён пример запроса в `http-client.http`.
|
||||
|
||||
## Tests
|
||||
- Добавить `tests/adapters/tbank/test_client.py` с проверкой: формирование payment request payload (auth, сумма в копейках, order uuid), маппинг успешного ответа в URL, маппинг 4xx в provider request error, маппинг 5xx/transport в client error, retry behavior если реализован.
|
||||
- Переименовать `tests/services/test_order.py` → `tests/services/test_init_payment.py`; обновить: stub payment adapter; success case включает вызов payment adapter и возврат `payment_url`; маппинг payment adapter errors в `InvalidInitPaymentRequestError` / `InitPaymentUnavailableError`.
|
||||
- Переименовать `tests/controllers/v1/test_order.py` → `tests/controllers/v1/test_init_payment.py`; обновить: endpoint `/init-payment`; payload содержит `price`; success response включает `payment_url`; запрос без `price` и с `price <= 0` возвращает 422; сценарии 400/503 продолжают работать.
|
||||
- Обновить `tests/config/test_config_sections.py` если добавлена новая обязательная секция в yaml.
|
||||
- При необходимости обновить `tests/smoke/test_app_import.py` для проверки wiring payment adapter.
|
||||
|
||||
## Commands
|
||||
- `poetry run pytest tests/adapters/tbank/test_client.py -q`
|
||||
- `poetry run pytest tests/services/test_init_payment.py -q`
|
||||
- `poetry run pytest tests/controllers/v1/test_init_payment.py -q`
|
||||
- `poetry run pytest tests/config/test_config_sections.py -q`
|
||||
- `poetry run pytest tests/smoke/test_app_import.py -q`
|
||||
- `python3 spec/gen_spec_index.py --check`
|
||||
Reference in New Issue
Block a user