Добавлена валидация цены

This commit is contained in:
Раис Юсупалиев
2026-05-13 16:35:22 +03:00
parent 6b66af5eb3
commit 39cd5ddc7a
24 changed files with 1021 additions and 56 deletions
+4
View File
@@ -18,5 +18,9 @@ class DeliveryProvider(ABC):
raise NotImplementedError raise NotImplementedError
class ProviderClientError(RuntimeError):
"""Raised when a provider call fails for temporary or provider-side reasons."""
class ProviderRequestError(RuntimeError): class ProviderRequestError(RuntimeError):
"""Raised when a provider rejects input request data.""" """Raised when a provider rejects input request data."""
+99 -5
View File
@@ -3,13 +3,21 @@
import asyncio import asyncio
from collections.abc import Awaitable, Callable from collections.abc import Awaitable, Callable
from typing import Any from typing import Any
import logging
import httpx import httpx
import structlog
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import (
DeliveryProvider,
ProviderClientError,
ProviderRequestError,
)
from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient from app.adapters.delivery_providers.cdek.auth import CDEKAuthClient
from app.adapters.delivery_providers.cdek.mapper import map_cdek_response from app.adapters.delivery_providers.cdek.mapper import (
CDEKMappingError,
map_cdek_response,
map_cdek_response_for_tariff_code,
)
from app.adapters.delivery_providers.cdek.order_mapper import ( from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderMappingError, CDEKOrderMappingError,
map_cdek_existing_order_response, map_cdek_existing_order_response,
@@ -21,10 +29,10 @@ from app.config import AdapterConfig
from app.schemas.payment import InitPaymentRequest from app.schemas.payment import InitPaymentRequest
from app.schemas.request import DeliveryCalculationRequest from app.schemas.request import DeliveryCalculationRequest
from app.schemas.response import DeliveryPrice from app.schemas.response import DeliveryPrice
log = logging.getLogger(__name__) log = structlog.get_logger(__name__)
class CDEKClientError(RuntimeError): class CDEKClientError(ProviderClientError):
"""Raised when CDEK tariff request fails.""" """Raised when CDEK tariff request fails."""
@@ -58,6 +66,29 @@ class CDEKClient:
self, request: DeliveryCalculationRequest self, request: DeliveryCalculationRequest
) -> dict[str, Any]: ) -> dict[str, Any]:
payload = await self._build_payload(request) payload = await self._build_payload(request)
return await self._post_tariff_payload(
payload,
request_error_message=None,
)
async def get_raw_payment_price(
self,
request: InitPaymentRequest,
) -> dict[str, Any]:
payload = self._build_payment_price_payload(request)
return await self._post_tariff_payload(
payload,
request_error_message=(
"CDEK payment price validation request was rejected with status"
),
)
async def _post_tariff_payload(
self,
payload: dict[str, Any],
*,
request_error_message: str | None,
) -> dict[str, Any]:
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
token = await self._auth_client.get_access_token() token = await self._auth_client.get_access_token()
@@ -79,10 +110,27 @@ class CDEKClient:
if attempt < self._retry_attempts: if attempt < self._retry_attempts:
await self._sleep(self._retry_delay(attempt)) await self._sleep(self._retry_delay(attempt))
continue continue
log.warning(
"cdek_tariff_request_server_error",
status_code=response.status_code,
response_body=_response_text_or_none(response),
request_payload=payload,
)
raise CDEKClientError( raise CDEKClientError(
f"CDEK tariff request failed with status {response.status_code}." f"CDEK tariff request failed with status {response.status_code}."
) )
if request_error_message is not None and 400 <= response.status_code < 500:
log.warning(
"cdek_tariff_request_rejected",
status_code=response.status_code,
response_body=_response_text_or_none(response),
request_payload=payload,
)
raise CDEKRequestError(
f"{request_error_message} {response.status_code}."
)
try: try:
response.raise_for_status() response.raise_for_status()
raw_payload = response.json() raw_payload = response.json()
@@ -182,6 +230,30 @@ class CDEKClient:
], ],
} }
@staticmethod
def _build_payment_price_payload(
request: InitPaymentRequest,
) -> dict[str, Any]:
payload: dict[str, Any] = {
"type": request.type,
"from_location": request.from_location.model_dump(mode="python"),
"to_location": request.to_location.model_dump(mode="python"),
"packages": [
{
"weight": package.weight * 1000,
"length": package.length,
"width": package.width,
"height": package.height,
}
for package in request.packages
],
}
if request.services is not None:
payload["services"] = [
service.model_dump(mode="python") for service in request.services
]
return payload
@staticmethod @staticmethod
def _get_cdek_city_code(city_id: int) -> int: def _get_cdek_city_code(city_id: int) -> int:
city_entry = cities_map.get(str(city_id)) city_entry = cities_map.get(str(city_id))
@@ -247,6 +319,21 @@ class CDEKProvider(DeliveryProvider):
raw_payload = await self._client.get_raw_price(request) raw_payload = await self._client.get_raw_price(request)
return map_cdek_response(raw_payload) return map_cdek_response(raw_payload)
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
raw_payload = await self._client.get_raw_payment_price(request)
try:
return map_cdek_response_for_tariff_code(
raw_payload,
tariff_code=request.tariff_code,
)
except CDEKMappingError as exc:
raise CDEKClientError(
"CDEK payment price validation response payload is invalid."
) from exc
async def register_order(self, request: InitPaymentRequest) -> str: async def register_order(self, request: InitPaymentRequest) -> str:
return await self._client.register_order(request) return await self._client.register_order(request)
@@ -256,3 +343,10 @@ def _response_json_or_none(response: httpx.Response) -> object | None:
return response.json() return response.json()
except (TypeError, ValueError): except (TypeError, ValueError):
return None return None
def _response_text_or_none(response: httpx.Response) -> str | None:
try:
return response.text
except Exception:
return None
@@ -19,6 +19,24 @@ def map_cdek_response(payload: dict[str, Any]) -> list[DeliveryPrice]:
] ]
def map_cdek_response_for_tariff_code(
payload: dict[str, Any],
*,
tariff_code: int,
) -> DeliveryPrice | None:
tariff_codes = payload.get("tariff_codes")
if not isinstance(tariff_codes, list):
raise CDEKMappingError("CDEK response must include tariff_codes.")
payload_currency = payload.get("currency")
for tariff in tariff_codes:
if not isinstance(tariff, dict):
raise CDEKMappingError("CDEK tariff entry must be an object.")
if _tariff_code_matches(tariff.get("tariff_code"), tariff_code):
return _map_tariff(tariff, payload_currency=payload_currency)
return None
def _map_tariff( def _map_tariff(
tariff: dict[str, Any], tariff: dict[str, Any],
*, *,
@@ -47,6 +65,8 @@ def _map_tariff(
currency=str(raw_currency).upper(), currency=str(raw_currency).upper(),
delivery_days_min=int(period_min), delivery_days_min=int(period_min),
delivery_days_max=int(period_max), delivery_days_max=int(period_max),
tariff_code=_extract_tariff_code(tariff.get("tariff_code")),
bypass_parcel_type_filter=True,
) )
except (ArithmeticError, TypeError, ValueError) as exc: except (ArithmeticError, TypeError, ValueError) as exc:
raise CDEKMappingError("CDEK response fields have invalid values.") from exc raise CDEKMappingError("CDEK response fields have invalid values.") from exc
@@ -62,3 +82,21 @@ def _get_tariffs(payload: dict[str, Any]) -> list[dict[str, Any]]:
raise CDEKMappingError("CDEK tariff entry must be an object.") raise CDEKMappingError("CDEK tariff entry must be an object.")
tariffs.append(tariff) tariffs.append(tariff)
return tariffs return tariffs
def _tariff_code_matches(value: object, expected_tariff_code: int) -> bool:
if isinstance(value, bool) or value is None:
return False
try:
return int(value) == expected_tariff_code
except (TypeError, ValueError):
return False
def _extract_tariff_code(value: object) -> int | None:
if isinstance(value, bool) or value is None:
return None
try:
return int(value)
except (TypeError, ValueError):
return None
+2 -1
View File
@@ -74,6 +74,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
providers=providers, providers=providers,
cache=cache, cache=cache,
payment_adapter=payment_adapter, payment_adapter=payment_adapter,
payment_price_validation_adapter=cdek_provider,
order_repository=order_repository, order_repository=order_repository,
order_registration_adapter=cdek_provider, order_registration_adapter=cdek_provider,
address_suggestion_providers=( address_suggestion_providers=(
@@ -166,7 +167,7 @@ async def suggest_addresses(
@router.post( @router.post(
"/init-payment", "/order",
response_model=InitPaymentResponse, response_model=InitPaymentResponse,
) )
async def init_payment( async def init_payment(
+56 -1
View File
@@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1
MIN_WEIGHT_KG = Decimal("0.01") MIN_WEIGHT_KG = Decimal("0.01")
MIN_DIMENSION_CM = Decimal("0.1") MIN_DIMENSION_CM = Decimal("0.1")
INTEGER_PRICE_QUANTIZER = Decimal("1") INTEGER_PRICE_QUANTIZER = Decimal("1")
KOPECKS_IN_RUBLE = Decimal("100")
DOC_SERVICE_MARKERS = ("документ", "document") DOC_SERVICE_MARKERS = ("документ", "document")
_MISSING = object() _MISSING = object()
@@ -46,6 +47,8 @@ class ProviderPrice:
currency: str currency: str
delivery_days_min: int delivery_days_min: int
delivery_days_max: int delivery_days_max: int
tariff_code: int | None = None
bypass_parcel_type_filter: bool = False
def normalize_delivery_request( def normalize_delivery_request(
@@ -118,6 +121,48 @@ def filter_and_sort_prices(
) )
def calculate_expected_payment_amount_kopecks(
provider_price: object,
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> int | None:
"""Return expected payment amount in kopecks for a RUB provider price."""
currency = _normalize_text(_get_optional_attr(provider_price, "currency")).upper()
if currency != "RUB":
return None
price = _try_to_decimal(_get_optional_attr(provider_price, "price"))
if price is None or not price.is_finite() or price <= 0:
return None
adjusted_price = _apply_price_multiplier_and_round(
price,
price_multiplier=_normalize_price_multiplier(price_multiplier),
)
if adjusted_price is None:
return None
return int(adjusted_price * KOPECKS_IN_RUBLE)
def is_init_payment_price_valid(
requested_amount_kopecks: object,
provider_price: object,
*,
price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
) -> bool:
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=price_multiplier,
)
if expected_amount_kopecks is None:
return False
requested_amount = _try_to_int(requested_amount_kopecks)
return requested_amount == expected_amount_kopecks
def filter_prices_by_parcel_type( def filter_prices_by_parcel_type(
prices: Iterable[ProviderPrice], prices: Iterable[ProviderPrice],
*, *,
@@ -132,7 +177,8 @@ def filter_prices_by_parcel_type(
return [ return [
price price
for price in prices for price in prices
if _matches_parcel_type(price.service_name, normalized_parcel_type) if price.bypass_parcel_type_filter
or _matches_parcel_type(price.service_name, normalized_parcel_type)
] ]
@@ -179,9 +225,18 @@ def _normalize_price(
currency=currency, currency=currency,
delivery_days_min=min_days, delivery_days_min=min_days,
delivery_days_max=max_days, delivery_days_max=max_days,
tariff_code=_try_to_int(_get_optional_attr(candidate, "tariff_code")),
bypass_parcel_type_filter=_extract_bypass_parcel_type_filter(candidate),
) )
def _extract_bypass_parcel_type_filter(candidate: object) -> bool:
value = _get_optional_attr(candidate, "bypass_parcel_type_filter")
if value is _MISSING or value is None:
return False
return bool(value)
def _normalize_weight(weight: Decimal, scale: int) -> Decimal: def _normalize_weight(weight: Decimal, scale: int) -> Decimal:
rounded = _round_half_up(weight, scale) rounded = _round_half_up(weight, scale)
if rounded < MIN_WEIGHT_KG: if rounded < MIN_WEIGHT_KG:
+1 -1
View File
@@ -48,7 +48,7 @@ class InitPaymentRequest(BaseModel):
order_uuid: str = Field(min_length=1) order_uuid: str = Field(min_length=1)
price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.") price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.")
type: Literal[2] type: Literal[2]
tariff_code: Literal[535] tariff_code: int
comment: str | None = None comment: str | None = None
sender: PaymentParty sender: PaymentParty
recipient: PaymentParty recipient: PaymentParty
+2
View File
@@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel):
currency: str = Field(min_length=3, max_length=3) currency: str = Field(min_length=3, max_length=3)
delivery_days_min: int = Field(ge=0) delivery_days_min: int = Field(ge=0)
delivery_days_max: int = Field(ge=0) delivery_days_max: int = Field(ge=0)
tariff_code: int | None = Field(default=None, ge=0)
bypass_parcel_type_filter: bool = Field(default=False, exclude=True)
class AddressSuggestion(BaseModel): class AddressSuggestion(BaseModel):
+99 -1
View File
@@ -15,7 +15,11 @@ from app.adapters.address_suggestions.base import (
AddressSuggestionProvider, AddressSuggestionProvider,
AddressSuggestionRequestError, AddressSuggestionRequestError,
) )
from app.adapters.delivery_providers.base import DeliveryProvider, ProviderRequestError from app.adapters.delivery_providers.base import (
DeliveryProvider,
ProviderClientError,
ProviderRequestError,
)
from app.adapters.tbank.base import ( from app.adapters.tbank.base import (
TBankPaymentAdapterError, TBankPaymentAdapterError,
TBankPaymentNotificationTokenError, TBankPaymentNotificationTokenError,
@@ -29,7 +33,9 @@ from app.domain.price import (
DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_PROVIDER_PRICE_MULTIPLIER,
DEFAULT_WEIGHT_ROUND_SCALE, DEFAULT_WEIGHT_ROUND_SCALE,
NormalizedDeliveryRequest, NormalizedDeliveryRequest,
calculate_expected_payment_amount_kopecks,
filter_and_sort_prices, filter_and_sort_prices,
is_init_payment_price_valid,
normalize_delivery_request, normalize_delivery_request,
) )
from app.repositories.order import OrderData from app.repositories.order import OrderData
@@ -95,6 +101,13 @@ class PaymentAdapterProtocol(Protocol):
) -> None: ... ) -> None: ...
class PaymentPriceValidationAdapterProtocol(Protocol):
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None: ...
class OrderRegistrationAdapterProtocol(Protocol): class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(self, request: InitPaymentRequest) -> str: ... async def register_order(self, request: InitPaymentRequest) -> str: ...
@@ -142,6 +155,9 @@ class AggregatorService:
providers: Sequence[DeliveryProvider], providers: Sequence[DeliveryProvider],
cache: PriceCacheProtocol | None = None, cache: PriceCacheProtocol | None = None,
payment_adapter: PaymentAdapterProtocol | None = None, payment_adapter: PaymentAdapterProtocol | None = None,
payment_price_validation_adapter: (
PaymentPriceValidationAdapterProtocol | None
) = None,
order_repository: OrderRepositoryProtocol | None = None, order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None, order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
@@ -154,6 +170,7 @@ class AggregatorService:
self._providers = tuple(providers) self._providers = tuple(providers)
self._cache = cache self._cache = cache
self._payment_adapter = payment_adapter self._payment_adapter = payment_adapter
self._payment_price_validation_adapter = payment_price_validation_adapter
self._order_repository = order_repository self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter self._order_registration_adapter = order_registration_adapter
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
@@ -237,6 +254,8 @@ class AggregatorService:
if self._payment_adapter is None: if self._payment_adapter is None:
raise InitPaymentUnavailableError("Payment adapter is not configured.") raise InitPaymentUnavailableError("Payment adapter is not configured.")
await self._validate_init_payment_price(request)
try: try:
payment_url = await self._payment_adapter.create_payment_link( payment_url = await self._payment_adapter.create_payment_link(
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
@@ -266,6 +285,85 @@ class AggregatorService:
await self._persist_order(request=request, payment_url=payment_url) await self._persist_order(request=request, payment_url=payment_url)
return InitPaymentResponse(payment_url=payment_url) return InitPaymentResponse(payment_url=payment_url)
async def _validate_init_payment_price(
self,
request: InitPaymentRequest,
) -> None:
if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError(
"Payment price validation adapter is not configured."
)
try:
provider_price = await self._payment_price_validation_adapter.get_payment_price(
request
)
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
error=str(exc),
)
raise InvalidInitPaymentRequestError(
"Payment init request is invalid for CDEK price validation."
) from exc
except ProviderClientError as exc:
logger.warning(
"init_payment_price_validation_unavailable",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
error=str(exc),
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
except Exception as exc:
logger.exception(
"init_payment_price_validation_unexpected_error",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
)
raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable."
) from exc
if provider_price is None:
logger.warning(
"init_payment_price_validation_tariff_not_found",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
)
raise InvalidInitPaymentRequestError(
"CDEK did not return the requested tariff for payment validation."
)
expected_amount_kopecks = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=self._provider_price_multiplier,
)
if not is_init_payment_price_valid(
request.price,
provider_price,
price_multiplier=self._provider_price_multiplier,
):
logger.warning(
"init_payment_price_mismatch",
order_uuid=request.order_uuid,
tariff_code=request.tariff_code,
requested_price_kopecks=request.price,
expected_price_kopecks=expected_amount_kopecks,
provider_currency=getattr(provider_price, "currency", None),
provider_price=str(getattr(provider_price, "price", None)),
)
raise InvalidInitPaymentRequestError(
"Payment amount does not match CDEK validated delivery price."
)
async def handle_tbank_payment_notification( async def handle_tbank_payment_notification(
self, self,
notification: TBankPaymentNotification, notification: TBankPaymentNotification,
+10 -10
View File
@@ -13,16 +13,6 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
volumes: volumes:
- ./config.yaml:/config.yaml - ./config.yaml:/config.yaml
migrations:
image: yusupal1ev/g2s-aggregator:0.0.2
container_name: g2s-aggregator-migrations
depends_on:
postgres:
condition: service_healthy
volumes:
- ./config.yaml:/config.yaml
command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no"
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: redis container_name: redis
@@ -45,5 +35,15 @@ services:
interval: 5s interval: 5s
timeout: 3s timeout: 3s
retries: 10 retries: 10
migrations:
image: yusupal1ev/g2s-aggregator:0.0.2
container_name: g2s-aggregator-migrations
depends_on:
postgres:
condition: service_healthy
volumes:
- ./config.yaml:/config.yaml
command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no"
volumes: volumes:
postgres: postgres:
+1 -1
View File
@@ -13,7 +13,7 @@ GET {{base_url}}/v2/orders?cdek_number=10240410458
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
### 3. Инициализация оплаты доставки ### 3. Инициализация оплаты доставки
POST http://localhost:8000/api/v1/delivery/init-payment POST http://localhost:8000/api/v1/delivery/order
Content-Type: application/json Content-Type: application/json
{ {
+4 -3
View File
@@ -36,10 +36,11 @@
| 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` | | 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` | | 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` | | 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` | | 030 | DONE | 2026-04-18 | Add TBank payment notification webhook and CDEK order creation | `spec/tasks/030_add_tbank_payment_notification_webhook.md` |
| 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
## Summary ## Summary
- Total: **31** - Total: **32**
- TODO: **1** - TODO: **1**
- DONE: **30** - DONE: **31**
+1 -1
View File
@@ -59,7 +59,7 @@
### Controller (`app/controllers/v1/delivery.py`) ### Controller (`app/controllers/v1/delivery.py`)
- `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]` - `POST /api/v1/delivery/price` — принимает `DeliveryCalculationRequest`, возвращает `list[DeliveryPrice]`
- `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]` - `POST /api/v1/delivery/suggest-address` — принимает `AddressSuggestRequest`, возвращает `list[AddressSuggestion]`
- `POST /api/v1/delivery/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse` - `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse`
- `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке - `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке
- Парсинг и валидация входных данных через Pydantic - Парсинг и валидация входных данных через Pydantic
- Маппинг исключений сервиса в HTTP-ответы - Маппинг исключений сервиса в HTTP-ответы
@@ -43,7 +43,7 @@ created: 2026-04-12
- Секция `postgres` присутствует в `_RequiredYamlSections`. - Секция `postgres` присутствует в `_RequiredYamlSections`.
- В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него. - В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него.
- Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`. - Wiring в `_build_aggregator_service` создаёт PostgreSQL engine, session factory, `OrderRepository` и передаёт его в `AggregatorService`.
- Запросы к эндпоинту `POST /api/v1/delivery/init-payment` продолжают возвращать `InitPaymentResponse` с `payment_url`. - Запросы к эндпоинту `POST /api/v1/delivery/order` продолжают возвращать `InitPaymentResponse` с `payment_url`.
## Definition of Done ## Definition of Done
- [ ] Создан модуль `app/adapters/postgres/` с engine/session factory. - [ ] Создан модуль `app/adapters/postgres/` с engine/session factory.
+2 -2
View File
@@ -16,7 +16,7 @@ TBank Init API должен получать URLs для обработки webh
- `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига. - `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига.
- TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling. - TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling.
- Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали. - Service и Controller MUST NOT содержать TBank-specific payload fields или provider HTTP-детали.
- Не изменять публичный request/response contract `POST /api/v1/delivery/init-payment`. - Не изменять публичный request/response contract `POST /api/v1/delivery/order`.
- Не изменять price flow, address suggestion flow, CDEK adapter и order repository. - Не изменять price flow, address suggestion flow, CDEK adapter и order repository.
- Не добавлять новые endpoints. - Не добавлять новые endpoints.
@@ -28,7 +28,7 @@ TBank Init API должен получать URLs для обработки webh
- Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload. - Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload.
- Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации. - Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации.
- `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов. - `AggregatorService.init_payment()` продолжает вызывать `payment_adapter.create_payment_link(order_uuid, price)` без дополнительных URL-аргументов.
- Endpoint `POST /api/v1/delivery/init-payment` продолжает возвращать только `InitPaymentResponse(payment_url=...)`. - Endpoint `POST /api/v1/delivery/order` продолжает возвращать только `InitPaymentResponse(payment_url=...)`.
## Definition of Done ## Definition of Done
- [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config. - [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config.
@@ -1,7 +1,7 @@
--- ---
id: 030 id: 030
title: Add TBank payment notification webhook and CDEK order creation title: Add TBank payment notification webhook and CDEK order creation
status: TODO status: DONE
created: 2026-04-18 created: 2026-04-18
--- ---
@@ -25,7 +25,7 @@ TBank Init API уже получает `NotificationURL`, а данные зая
- Repository содержит только CRUD/query/update primitives; без workflow logic и business decisions. - 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 и никогда не создавали дубль заказа. - 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`, а не как ошибку с повторной регистрацией. - 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. - Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow.
- Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок. - Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`. - Не изменять файлы в `spec/`.
@@ -0,0 +1,71 @@
---
id: 031
title: Validate init-payment price with CDEK tariff
status: TODO
created: 2026-04-18
---
## Context
`POST /api/v1/delivery/order` сейчас принимает `price` от клиента и использует это значение как сумму платежа TBank. Клиент может передать произвольную сумму, не соответствующую тарифу CDEK.
## Goal
Перед созданием ссылки на оплату валидировать `InitPaymentRequest.price` через CDEK `POST /calculator/tarifflist` для данных заявки. Если сумма не совпадает с валидированной суммой CDEK, не создавать payment link и вернуть детерминированную ошибку.
## Constraints
- Controller не должен содержать business logic или provider-specific branching.
- Endpoint `POST /api/v1/delivery/order` должен по-прежнему вызывать ровно один метод Service: `AggregatorService.init_payment()`.
- Service выполняет только orchestration: вызывает injected CDEK adapter для расчёта цены, делегирует сравнение в Business Logic, затем вызывает TBank adapter только при успешной валидации.
- Pure правило сравнения цены должно жить в `app/domain/`: сравнение выполняется в копейках, с применением существующего provider price multiplier и округления `ROUND_HALF_UP`.
- Внешний IO для CDEK должен оставаться только в CDEK adapter; TBank-specific logic остаётся только в TBank adapter.
- CDEK validation MUST использовать существующий endpoint CDEK `POST /calculator/tarifflist`; запрещено использовать `/orders`, `/calculator/tariff` или registration flow для проверки цены.
- CDEK adapter должен переиспользовать существующие auth, retry, timeout, HTTP error handling и response mapping для tariff list там, где это совместимо с `InitPaymentRequest`.
- CDEK validation должна использовать данные существующего `InitPaymentRequest`: `tariff_code`, `from_location`, `to_location`, `packages` и `services` при наличии.
- Публичный request/response contract `POST /api/v1/delivery/order` не менять, кроме ужесточения runtime validation поля `price`.
- При mismatch цены нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- При ошибке CDEK validation нельзя вызывать TBank adapter и нельзя сохранять заявку в PostgreSQL.
- Не изменять price flow, address suggestion flow, payment notification webhook flow и CDEK order registration flow.
- Не добавлять новые endpoints, payment providers, ручной retry endpoint или endpoint чтения заявок.
- Не изменять файлы в `spec/`.
## Acceptance criteria
- CDEK adapter предоставляет service-facing метод для расчёта валидной цены доставки по `InitPaymentRequest` через `POST /calculator/tarifflist` без регистрации заказа в CDEK.
- Новый метод переиспользует существующую tariff-list инфраструктуру CDEK adapter: OAuth2 token, retry/timeout policy, handling 4xx/5xx/transport errors и mapping `tariff_codes` в internal price model.
- CDEK validation request использует `request.tariff_code`, `from_location`, `to_location`, `packages` и `services` из `InitPaymentRequest`.
- CDEK validation request отправляется на тот же configured base URL path `/calculator/tarifflist`, который используется текущим CDEK price flow.
- Если CDEK не возвращает цену для `request.tariff_code`, service завершает `init_payment()` детерминированной `InvalidInitPaymentRequestError`.
- Business Logic рассчитывает expected payment amount в копейках: provider price в `RUB` после существующего multiplier и `ROUND_HALF_UP` конвертируется в копейки и сравнивается с `InitPaymentRequest.price`.
- Если `InitPaymentRequest.price` не равен expected amount, `AggregatorService.init_payment()` поднимает `InvalidInitPaymentRequestError`.
- При успешной price validation `AggregatorService.init_payment()` вызывает TBank adapter с исходным `order_uuid` и валидированным `price`.
- При успешной price validation и успешном TBank response сохранение заявки в PostgreSQL остаётся прежним.
- При CDEK provider request error service маппит ошибку в `InvalidInitPaymentRequestError`.
- При CDEK transport, timeout или 5xx error service маппит ошибку в `InitPaymentUnavailableError`.
- Controller маппит `InvalidInitPaymentRequestError` в HTTP 400 и `InitPaymentUnavailableError` в HTTP 503 по текущему error contract.
- Tests подтверждают, что при mismatch цены TBank adapter и OrderRepository не вызываются.
## Definition of Done
- [ ] Добавлен service-facing CDEK adapter method для расчёта цены по `InitPaymentRequest` через `POST /calculator/tarifflist`.
- [ ] Метод переиспользует существующие auth, retry, timeout, error handling и tariff response mapping CDEK adapter.
- [ ] Добавлена pure Business Logic для сравнения requested price с CDEK validated price в копейках.
- [ ] `AggregatorService.init_payment()` выполняет CDEK price validation до вызова TBank adapter.
- [ ] Ошибки CDEK validation маппятся в существующие service/controller error paths.
- [ ] TBank adapter вызывается только после успешной CDEK price validation.
- [ ] OrderRepository вызывается только после успешной CDEK price validation и успешного получения `payment_url`.
- [ ] Публичный contract `POST /api/v1/delivery/order` не изменён, кроме runtime rejection невалидной цены.
- [ ] Обновлены tests для domain, CDEK adapter, service и controller.
- [ ] Все команды из раздела Commands проходят.
## Tests
- Добавить или обновить `tests/domain/test_payment_price_validation.py`: exact match, mismatch, multiplier с `ROUND_HALF_UP`, конвертация `RUB` в копейки, не-`RUB` currency как deterministic validation failure.
- Добавить или обновить `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py`: validation request отправляется на `/calculator/tarifflist`; payload строится из `InitPaymentRequest`, используется `tariff_code`, `packages`, `services`, `from_location`, `to_location`; success/error mapping покрыт stubs/mocks.
- Обновить `tests/services/test_init_payment.py`: success вызывает CDEK validation до TBank; mismatch возвращает `InvalidInitPaymentRequestError` без TBank и repository calls; CDEK request error маппится в `InvalidInitPaymentRequestError`; CDEK client error маппится в `InitPaymentUnavailableError`.
- Обновить `tests/controllers/v1/test_init_payment.py`: price mismatch возвращает HTTP 400; временная ошибка CDEK validation возвращает HTTP 503; controller продолжает делегировать ровно один service method.
- При необходимости обновить `tests/smoke/test_app_import.py` для wiring нового CDEK validation dependency.
## Commands
- `poetry run pytest tests/domain/test_payment_price_validation.py -q`
- `poetry run pytest tests/adapters/delivery_providers/cdek/test_payment_price_validation.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/smoke/test_app_import.py -q`
- `poetry run pytest -q`
- `python3 spec/gen_spec_index.py --check`
@@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
"currency": "usd", "currency": "usd",
"tariff_codes": [ "tariff_codes": [
{ {
"tariff_code": 7,
"tariff_name": "Express", "tariff_name": "Express",
"delivery_sum": "1234.50", "delivery_sum": "1234.50",
"currency": "rub", "currency": "rub",
@@ -33,6 +34,25 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None:
assert [price.currency for price in result] == ["RUB", "USD"] assert [price.currency for price in result] == ["RUB", "USD"]
assert [price.delivery_days_min for price in result] == [2, 5] assert [price.delivery_days_min for price in result] == [2, 5]
assert [price.delivery_days_max for price in result] == [4, 7] assert [price.delivery_days_max for price in result] == [4, 7]
assert [price.tariff_code for price in result] == [7, 136]
def test_map_cdek_response_returns_none_tariff_code_when_missing() -> None:
payload = {
"tariff_codes": [
{
"tariff_name": "Express",
"delivery_sum": "100.00",
"currency": "RUB",
"period_min": 1,
"period_max": 2,
}
]
}
result = map_cdek_response(payload)
assert result[0].tariff_code is None
def test_map_cdek_response_raises_for_missing_tariff_codes() -> None: def test_map_cdek_response_raises_for_missing_tariff_codes() -> None:
@@ -0,0 +1,265 @@
import asyncio
from decimal import Decimal
from typing import Any
import httpx
import pytest
from app.adapters.delivery_providers.cdek.client import (
CDEKClient,
CDEKClientError,
CDEKProvider,
CDEKRequestError,
)
from app.schemas.payment import InitPaymentRequest
class StubAuthClient:
async def get_access_token(self) -> str:
return "test-token"
class SequenceHTTPClient:
def __init__(self, results: list[Any]) -> None:
self._results = results
self.calls: list[dict[str, Any]] = []
def _next_result(self) -> Any:
return self._results[len(self.calls) - 1]
async def post(
self,
url: str,
*,
json: dict[str, Any] | None = None,
data: dict[str, Any] | None = None,
headers: dict[str, str] | None = None,
timeout: float | None = None,
) -> httpx.Response:
self.calls.append(
{
"method": "POST",
"url": url,
"json": json,
"data": data,
"headers": headers,
"timeout": timeout,
}
)
result = self._next_result()
if isinstance(result, Exception):
raise result
return result
def _make_request(**overrides: object) -> InitPaymentRequest:
payload: dict[str, object] = {
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test payment",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"number": "+79001234567"},
},
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
}
payload.update(overrides)
return InitPaymentRequest(**payload)
def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_tariff() -> None:
response = httpx.Response(
200,
json={
"tariff_codes": [
{
"tariff_code": 234,
"tariff_name": "Other tariff",
"delivery_sum": "999.00",
"currency": "RUB",
"period_min": 3,
"period_max": 5,
},
{
"tariff_code": 535,
"tariff_name": "CDEK tariff",
"delivery_sum": "1250.00",
"currency": "RUB",
"period_min": 1,
"period_max": 2,
},
]
},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
timeout_seconds=7.5,
retry_attempts=0,
)
)
result = asyncio.run(provider.get_payment_price(_make_request()))
assert result is not None
assert result.provider == "cdek"
assert result.service_name == "CDEK tariff"
assert result.price == Decimal("1250.00")
assert http_client.calls == [
{
"method": "POST",
"url": "https://api.cdek.test/v2/calculator/tarifflist",
"json": {
"type": 2,
"from_location": {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"packages": [
{
"weight": 1000,
"length": 20,
"width": 15,
"height": 10,
}
],
"services": [{"code": "INSURANCE", "parameter": "1000"}],
},
"data": None,
"headers": {"Authorization": "Bearer test-token"},
"timeout": 7.5,
}
]
def test_provider_get_payment_price_omits_services_when_none() -> None:
response = httpx.Response(
200,
json={"tariff_codes": []},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
)
result = asyncio.run(provider.get_payment_price(_make_request(services=None)))
assert result is None
assert "services" not in http_client.calls[0]["json"]
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
rejected_response = httpx.Response(
422,
json={"errors": [{"message": "bad request"}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([rejected_response])
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=2,
)
with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.get_raw_payment_price(_make_request()))
assert len(http_client.calls) == 1
def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> None:
first_response = httpx.Response(
503,
json={"message": "temporary failure"},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
second_response = httpx.Response(
503,
json={"message": "temporary failure"},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([first_response, second_response])
sleep_calls: list[float] = []
async def fake_sleep(seconds: float) -> None:
sleep_calls.append(seconds)
client = CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=1,
retry_backoff_seconds=0.25,
sleep=fake_sleep,
)
with pytest.raises(CDEKClientError, match="status 503"):
asyncio.run(client.get_raw_payment_price(_make_request()))
assert len(http_client.calls) == 2
assert sleep_calls == [0.25]
def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error() -> None:
invalid_response = httpx.Response(
200,
json={"tariff_codes": [{"tariff_code": 535}]},
request=httpx.Request("POST", "https://api.cdek.test/v2/calculator/tarifflist"),
)
http_client = SequenceHTTPClient([invalid_response])
provider = CDEKProvider(
CDEKClient(
http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2",
retry_attempts=0,
)
)
with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(provider.get_payment_price(_make_request()))
+5 -3
View File
@@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
"currency": "RUB", "currency": "RUB",
"delivery_days_min": 2, "delivery_days_min": 2,
"delivery_days_max": 3, "delivery_days_max": 3,
"tariff_code": None,
}, },
{ {
"provider": "stub-provider", "provider": "stub-provider",
@@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency(
"currency": "RUB", "currency": "RUB",
"delivery_days_min": 4, "delivery_days_min": 4,
"delivery_days_max": 5, "delivery_days_max": 5,
"tariff_code": None,
} }
] ]
assert second_response.json() == first_response.json() assert second_response.json() == first_response.json()
@@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type(
) -> None: ) -> None:
provider = StubPriceProvider( provider = StubPriceProvider(
response=[ response=[
_make_price(service_name="Parcel locker", price="90.00"), _make_price(service_name="Parcel locker", price="90.00", provider="other"),
_make_price(service_name="Срочный документ", price="150.00"), _make_price(service_name="Срочный документ", price="150.00", provider="other"),
_make_price(service_name="DOCUMENT EXPRESS", price="200.00"), _make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"),
] ]
) )
service = AggregatorService(providers=[provider]) service = AggregatorService(providers=[provider])
+75 -13
View File
@@ -85,7 +85,7 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=_valid_payload(), json=_valid_payload(),
) )
@@ -101,7 +101,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = _valid_payload()
invalid_payload["tariff_code"] = 136 invalid_payload["tariff_code"] = "not-a-number"
async def run_request() -> httpx.Response: async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app) transport = httpx.ASGITransport(app=app)
@@ -110,7 +110,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -134,7 +134,7 @@ def test_post_init_payment_rejects_missing_price() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -158,7 +158,7 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -182,7 +182,7 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -209,7 +209,7 @@ def test_post_init_payment_rejects_sender_company_field() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -236,7 +236,7 @@ def test_post_init_payment_rejects_sender_phones_field() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -263,7 +263,7 @@ def test_post_init_payment_rejects_recipient_company_field() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -290,7 +290,7 @@ def test_post_init_payment_rejects_recipient_phones_field() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=invalid_payload, json=invalid_payload,
) )
@@ -314,7 +314,7 @@ def test_post_init_payment_accepts_request_without_services() -> None:
transport=transport, transport=transport,
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post("/api/v1/delivery/init-payment", json=payload) return await client.post("/api/v1/delivery/order", json=payload)
response = asyncio.run(run_request()) response = asyncio.run(run_request())
@@ -338,7 +338,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=_valid_payload(), json=_valid_payload(),
) )
@@ -353,6 +353,37 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
} }
def test_post_init_payment_maps_price_mismatch_to_400() -> None:
service = StubAggregatorService(
response=None,
error=InvalidInitPaymentRequestError("price mismatch"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 400
assert response.json() == {
"detail": {
"code": "invalid_init_payment_request",
"message": "Payment request contains invalid or unsupported TBank data.",
}
}
assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_post_init_payment_maps_service_exception_to_503() -> None: def test_post_init_payment_maps_service_exception_to_503() -> None:
service = StubAggregatorService( service = StubAggregatorService(
response=None, response=None,
@@ -368,7 +399,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
base_url="http://testserver", base_url="http://testserver",
) as client: ) as client:
return await client.post( return await client.post(
"/api/v1/delivery/init-payment", "/api/v1/delivery/order",
json=_valid_payload(), json=_valid_payload(),
) )
@@ -383,6 +414,37 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
} }
def test_post_init_payment_maps_temporary_cdek_validation_error_to_503() -> None:
service = StubAggregatorService(
response=None,
error=InitPaymentUnavailableError("CDEK validation unavailable"),
)
app = create_app()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=_valid_payload(),
)
response = asyncio.run(run_request())
assert response.status_code == 503
assert response.json() == {
"detail": {
"code": "init_payment_unavailable",
"message": "Payment initialization is temporarily unavailable.",
}
}
assert service.calls == [InitPaymentRequest(**_valid_payload())]
def test_delivery_order_endpoint_is_removed() -> None: def test_delivery_order_endpoint_is_removed() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
@@ -0,0 +1,62 @@
from decimal import Decimal
from app.domain.price import (
calculate_expected_payment_amount_kopecks,
is_init_payment_price_valid,
)
from app.schemas.response import DeliveryPrice
def _make_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
"service_name": "CDEK tariff",
"price": Decimal("1250.00"),
"currency": "RUB",
"delivery_days_min": 1,
"delivery_days_max": 2,
}
payload.update(overrides)
return DeliveryPrice.model_construct(**payload)
def test_init_payment_price_validation_accepts_exact_match() -> None:
provider_price = _make_price(price=Decimal("1250.00"))
assert is_init_payment_price_valid(125000, provider_price)
assert calculate_expected_payment_amount_kopecks(provider_price) == 125000
def test_init_payment_price_validation_rejects_mismatch() -> None:
provider_price = _make_price(price=Decimal("1250.00"))
assert not is_init_payment_price_valid(124999, provider_price)
def test_init_payment_price_validation_applies_multiplier_and_rounds_half_up() -> None:
provider_price = _make_price(price=Decimal("100.50"))
expected_amount = calculate_expected_payment_amount_kopecks(
provider_price,
price_multiplier=Decimal("1.1"),
)
assert expected_amount == 11100
assert is_init_payment_price_valid(
11100,
provider_price,
price_multiplier=Decimal("1.1"),
)
def test_init_payment_price_validation_converts_rub_to_kopecks() -> None:
provider_price = _make_price(price=Decimal("899.00"))
assert calculate_expected_payment_amount_kopecks(provider_price) == 89900
def test_init_payment_price_validation_rejects_non_rub_currency() -> None:
provider_price = _make_price(price=Decimal("899.00"), currency="USD")
assert calculate_expected_payment_amount_kopecks(provider_price) is None
assert not is_init_payment_price_valid(89900, provider_price)
+35
View File
@@ -106,6 +106,41 @@ def test_filter_prices_by_parcel_type_returns_non_document_tariffs_for_parcel()
assert [price.service_name for price in result] == ["Economy parcel", "Express"] assert [price.service_name for price in result] == ["Economy parcel", "Express"]
def test_filter_prices_by_parcel_type_keeps_prices_with_bypass_flag_regardless_of_type() -> None:
bypass_document = _make_price(
provider="cdek",
service_name="Документ курьером",
bypass_parcel_type_filter=True,
)
bypass_parcel = _make_price(
provider="cdek",
service_name="Посылка склад-склад",
bypass_parcel_type_filter=True,
)
other_document = _make_price(provider="other", service_name="Document")
other_parcel = _make_price(provider="other", service_name="Parcel")
doc_result = filter_prices_by_parcel_type(
[bypass_document, bypass_parcel, other_document, other_parcel],
parcel_type=ParcelType.DOC,
)
parcel_result = filter_prices_by_parcel_type(
[bypass_document, bypass_parcel, other_document, other_parcel],
parcel_type=ParcelType.PARCEL,
)
assert [price.service_name for price in doc_result] == [
"Документ курьером",
"Посылка склад-склад",
"Document",
]
assert [price.service_name for price in parcel_result] == [
"Документ курьером",
"Посылка склад-склад",
"Parcel",
]
def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None: def test_filter_prices_by_parcel_type_returns_all_prices_when_type_is_missing() -> None:
prices = [ prices = [
_make_price(service_name="Документ"), _make_price(service_name="Документ"),
+5 -5
View File
@@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None:
StubCache( StubCache(
forced_get_value=[ forced_get_value=[
_make_price( _make_price(
"cdek", "other",
"100.40", "100.40",
service_name="DOCUMENT EXPRESS", service_name="DOCUMENT EXPRESS",
).model_dump(mode="json"), ).model_dump(mode="json"),
_make_price( _make_price(
"cdek", "other",
"200.40", "200.40",
service_name="Economy parcel", service_name="Economy parcel",
).model_dump(mode="json"), ).model_dump(mode="json"),
@@ -238,10 +238,10 @@ def test_get_all_prices_applies_same_parcel_type_filter_for_fresh_and_cached_res
expected_provider_calls: int, expected_provider_calls: int,
) -> None: ) -> None:
provider = StubProvider( provider = StubProvider(
name="cdek", name="other",
response=[ response=[
_make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"), _make_price("other", "100.40", service_name="DOCUMENT EXPRESS"),
_make_price("cdek", "200.40", service_name="Economy parcel"), _make_price("other", "200.40", service_name="Economy parcel"),
], ],
) )
service = AggregatorService( service = AggregatorService(
+161 -6
View File
@@ -1,13 +1,16 @@
import asyncio import asyncio
from decimal import Decimal
import pytest import pytest
from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError
from app.adapters.tbank.base import ( from app.adapters.tbank.base import (
TBankPaymentAdapterError, TBankPaymentAdapterError,
TBankPaymentRequestError, TBankPaymentRequestError,
) )
from app.repositories.order import OrderData from app.repositories.order import OrderData
from app.schemas.payment import InitPaymentRequest, InitPaymentResponse from app.schemas.payment import InitPaymentRequest, InitPaymentResponse
from app.schemas.response import DeliveryPrice
from app.services.aggregator import ( from app.services.aggregator import (
AggregatorService, AggregatorService,
InitPaymentUnavailableError, InitPaymentUnavailableError,
@@ -21,13 +24,17 @@ class StubPaymentAdapter:
*, *,
response: str | None = None, response: str | None = None,
error: Exception | None = None, error: Exception | None = None,
events: list[str] | None = None,
) -> None: ) -> None:
self._response = response self._response = response
self._error = error self._error = error
self._events = events
self.calls: list[tuple[str, int]] = [] self.calls: list[tuple[str, int]] = []
async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str:
self.calls.append((order_uuid, amount_kopecks)) self.calls.append((order_uuid, amount_kopecks))
if self._events is not None:
self._events.append("tbank")
if self._error is not None: if self._error is not None:
raise self._error raise self._error
if self._response is None: if self._response is None:
@@ -35,6 +42,31 @@ class StubPaymentAdapter:
return self._response return self._response
class StubPaymentPriceValidationAdapter:
def __init__(
self,
*,
response: DeliveryPrice | None = None,
error: Exception | None = None,
events: list[str] | None = None,
) -> None:
self._response = response
self._error = error
self._events = events
self.calls: list[InitPaymentRequest] = []
async def get_payment_price(
self,
request: InitPaymentRequest,
) -> DeliveryPrice | None:
self.calls.append(request)
if self._events is not None:
self._events.append("cdek")
if self._error is not None:
raise self._error
return self._response
class StubOrderSessionContext: class StubOrderSessionContext:
def __init__(self, session: object) -> None: def __init__(self, session: object) -> None:
self._session = session self._session = session
@@ -105,30 +137,57 @@ def _make_init_payment_request(**overrides: object) -> InitPaymentRequest:
return InitPaymentRequest(**payload) return InitPaymentRequest(**payload)
def test_init_payment_delegates_to_adapter_and_returns_payment_url() -> None: def _make_cdek_price(**overrides: object) -> DeliveryPrice:
payload = {
"provider": "cdek",
"service_name": "CDEK tariff",
"price": Decimal("1250.00"),
"currency": "RUB",
"delivery_days_min": 1,
"delivery_days_max": 2,
}
payload.update(overrides)
return DeliveryPrice.model_construct(**payload)
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
request = _make_init_payment_request() request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1") events: list[str] = []
service = AggregatorService(providers=[], payment_adapter=adapter) validation_adapter = StubPaymentPriceValidationAdapter(
response=_make_cdek_price(),
events=events,
)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1", events=events)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
result = asyncio.run(service.init_payment(request)) result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
assert events == ["cdek", "tbank"]
def test_init_payment_persists_order_after_successful_payment_link() -> None: def test_init_payment_persists_order_after_successful_payment_link() -> None:
request = _make_init_payment_request() request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1") adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository() order_repository = StubOrderRepository()
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
payment_adapter=adapter, payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository, order_repository=order_repository,
) )
result = asyncio.run(service.init_payment(request)) result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
assert order_repository.calls == [ assert order_repository.calls == [
( (
@@ -179,16 +238,19 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None: def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None:
request = _make_init_payment_request() request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1") adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository(error=RuntimeError("database down")) order_repository = StubOrderRepository(error=RuntimeError("database down"))
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
payment_adapter=adapter, payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository, order_repository=order_repository,
) )
result = asyncio.run(service.init_payment(request)) result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
assert len(order_repository.calls) == 1 assert len(order_repository.calls) == 1
@@ -196,22 +258,34 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None: def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() -> None:
request = _make_init_payment_request() request = _make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload")) adapter = StubPaymentAdapter(error=TBankPaymentRequestError("bad payload"))
service = AggregatorService(providers=[], payment_adapter=adapter) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError): with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request)) asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None: def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None:
request = _make_init_payment_request(services=None) request = _make_init_payment_request(services=None)
adapter = StubPaymentAdapter(response="https://pay.test/payment/2") adapter = StubPaymentAdapter(response="https://pay.test/payment/2")
service = AggregatorService(providers=[], payment_adapter=adapter) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
result = asyncio.run(service.init_payment(request)) result = asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2") assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2")
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
assert request.services is None assert request.services is None
@@ -219,11 +293,17 @@ def test_init_payment_delegates_request_with_services_none_without_new_logic() -
def test_init_payment_maps_client_failures_to_unavailable_error() -> None: def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
request = _make_init_payment_request() request = _make_init_payment_request()
adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down")) adapter = StubPaymentAdapter(error=TBankPaymentAdapterError("transport down"))
service = AggregatorService(providers=[], payment_adapter=adapter) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InitPaymentUnavailableError): with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request)) asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == [("order-uuid-1", 125000)] assert adapter.calls == [("order-uuid-1", 125000)]
@@ -232,3 +312,78 @@ def test_init_payment_without_configured_adapter_raises_unavailable_error() -> N
with pytest.raises(InitPaymentUnavailableError): with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(_make_init_payment_request())) asyncio.run(service.init_payment(_make_init_payment_request()))
def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
request = _make_init_payment_request(price=124999)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository()
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
order_repository=order_repository,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
assert order_repository.calls == []
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=None)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderRequestError("bad CDEK payload")
)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InvalidInitPaymentRequestError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
request = _make_init_payment_request()
adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderClientError("CDEK unavailable")
)
service = AggregatorService(
providers=[],
payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter,
)
with pytest.raises(InitPaymentUnavailableError):
asyncio.run(service.init_payment(request))
assert validation_adapter.calls == [request]
assert adapter.calls == []