diff --git a/app/adapters/delivery_providers/base.py b/app/adapters/delivery_providers/base.py index ea57521..8d595ff 100644 --- a/app/adapters/delivery_providers/base.py +++ b/app/adapters/delivery_providers/base.py @@ -18,5 +18,9 @@ class DeliveryProvider(ABC): raise NotImplementedError +class ProviderClientError(RuntimeError): + """Raised when a provider call fails for temporary or provider-side reasons.""" + + class ProviderRequestError(RuntimeError): """Raised when a provider rejects input request data.""" diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index 7b3f3be..ba71da3 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -3,13 +3,21 @@ import asyncio from collections.abc import Awaitable, Callable from typing import Any -import logging 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.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 ( CDEKOrderMappingError, map_cdek_existing_order_response, @@ -21,10 +29,10 @@ from app.config import AdapterConfig from app.schemas.payment import InitPaymentRequest from app.schemas.request import DeliveryCalculationRequest 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.""" @@ -58,6 +66,29 @@ class CDEKClient: self, request: DeliveryCalculationRequest ) -> dict[str, Any]: 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): try: token = await self._auth_client.get_access_token() @@ -79,10 +110,27 @@ class CDEKClient: if attempt < self._retry_attempts: await self._sleep(self._retry_delay(attempt)) 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( 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: response.raise_for_status() 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 def _get_cdek_city_code(city_id: int) -> int: city_entry = cities_map.get(str(city_id)) @@ -247,6 +319,21 @@ class CDEKProvider(DeliveryProvider): raw_payload = await self._client.get_raw_price(request) 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: return await self._client.register_order(request) @@ -256,3 +343,10 @@ def _response_json_or_none(response: httpx.Response) -> object | None: return response.json() except (TypeError, ValueError): return None + + +def _response_text_or_none(response: httpx.Response) -> str | None: + try: + return response.text + except Exception: + return None diff --git a/app/adapters/delivery_providers/cdek/mapper.py b/app/adapters/delivery_providers/cdek/mapper.py index e3a4e78..9ebc72a 100644 --- a/app/adapters/delivery_providers/cdek/mapper.py +++ b/app/adapters/delivery_providers/cdek/mapper.py @@ -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( tariff: dict[str, Any], *, @@ -47,6 +65,8 @@ def _map_tariff( currency=str(raw_currency).upper(), delivery_days_min=int(period_min), 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: 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.") tariffs.append(tariff) 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 diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index 44969da..efab3ce 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -74,6 +74,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService: providers=providers, cache=cache, payment_adapter=payment_adapter, + payment_price_validation_adapter=cdek_provider, order_repository=order_repository, order_registration_adapter=cdek_provider, address_suggestion_providers=( @@ -166,7 +167,7 @@ async def suggest_addresses( @router.post( - "/init-payment", + "/order", response_model=InitPaymentResponse, ) async def init_payment( diff --git a/app/domain/price.py b/app/domain/price.py index df5798f..37ca60a 100644 --- a/app/domain/price.py +++ b/app/domain/price.py @@ -12,6 +12,7 @@ DIMENSIONS_ROUND_SCALE = 1 MIN_WEIGHT_KG = Decimal("0.01") MIN_DIMENSION_CM = Decimal("0.1") INTEGER_PRICE_QUANTIZER = Decimal("1") +KOPECKS_IN_RUBLE = Decimal("100") DOC_SERVICE_MARKERS = ("документ", "document") _MISSING = object() @@ -46,6 +47,8 @@ class ProviderPrice: currency: str delivery_days_min: int delivery_days_max: int + tariff_code: int | None = None + bypass_parcel_type_filter: bool = False 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( prices: Iterable[ProviderPrice], *, @@ -132,7 +177,8 @@ def filter_prices_by_parcel_type( return [ price 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, delivery_days_min=min_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: rounded = _round_half_up(weight, scale) if rounded < MIN_WEIGHT_KG: diff --git a/app/schemas/payment.py b/app/schemas/payment.py index a0b8c57..fb86000 100644 --- a/app/schemas/payment.py +++ b/app/schemas/payment.py @@ -48,7 +48,7 @@ class InitPaymentRequest(BaseModel): order_uuid: str = Field(min_length=1) price: int = Field(gt=0, strict=True, description="Payment amount in kopecks.") type: Literal[2] - tariff_code: Literal[535] + tariff_code: int comment: str | None = None sender: PaymentParty recipient: PaymentParty diff --git a/app/schemas/response.py b/app/schemas/response.py index 1280777..e0e050e 100644 --- a/app/schemas/response.py +++ b/app/schemas/response.py @@ -12,6 +12,8 @@ class DeliveryPrice(BaseModel): currency: str = Field(min_length=3, max_length=3) delivery_days_min: 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): diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 412afc3..479a395 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -15,7 +15,11 @@ from app.adapters.address_suggestions.base import ( AddressSuggestionProvider, 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 ( TBankPaymentAdapterError, TBankPaymentNotificationTokenError, @@ -29,7 +33,9 @@ from app.domain.price import ( DEFAULT_PROVIDER_PRICE_MULTIPLIER, DEFAULT_WEIGHT_ROUND_SCALE, NormalizedDeliveryRequest, + calculate_expected_payment_amount_kopecks, filter_and_sort_prices, + is_init_payment_price_valid, normalize_delivery_request, ) from app.repositories.order import OrderData @@ -95,6 +101,13 @@ class PaymentAdapterProtocol(Protocol): ) -> None: ... +class PaymentPriceValidationAdapterProtocol(Protocol): + async def get_payment_price( + self, + request: InitPaymentRequest, + ) -> DeliveryPrice | None: ... + + class OrderRegistrationAdapterProtocol(Protocol): async def register_order(self, request: InitPaymentRequest) -> str: ... @@ -142,6 +155,9 @@ class AggregatorService: providers: Sequence[DeliveryProvider], cache: PriceCacheProtocol | None = None, payment_adapter: PaymentAdapterProtocol | None = None, + payment_price_validation_adapter: ( + PaymentPriceValidationAdapterProtocol | None + ) = None, order_repository: OrderRepositoryProtocol | None = None, order_registration_adapter: OrderRegistrationAdapterProtocol | None = None, address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), @@ -154,6 +170,7 @@ class AggregatorService: self._providers = tuple(providers) self._cache = cache self._payment_adapter = payment_adapter + self._payment_price_validation_adapter = payment_price_validation_adapter self._order_repository = order_repository self._order_registration_adapter = order_registration_adapter self._weight_round_scale = weight_round_scale @@ -237,6 +254,8 @@ class AggregatorService: if self._payment_adapter is None: raise InitPaymentUnavailableError("Payment adapter is not configured.") + await self._validate_init_payment_price(request) + try: payment_url = await self._payment_adapter.create_payment_link( order_uuid=request.order_uuid, @@ -266,6 +285,85 @@ class AggregatorService: await self._persist_order(request=request, 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( self, notification: TBankPaymentNotification, diff --git a/docker-compose.yml b/docker-compose.yml index a01b60d..beff946 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -13,16 +13,6 @@ services: condition: service_completed_successfully volumes: - ./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: image: redis:7-alpine container_name: redis @@ -45,5 +35,15 @@ services: interval: 5s timeout: 3s 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: postgres: diff --git a/http-client.http b/http-client.http index 4b8ae63..d09b74c 100644 --- a/http-client.http +++ b/http-client.http @@ -13,7 +13,7 @@ GET {{base_url}}/v2/orders?cdek_number=10240410458 Authorization: Bearer {{auth_token}} ### 3. Инициализация оплаты доставки -POST http://localhost:8000/api/v1/delivery/init-payment +POST http://localhost:8000/api/v1/delivery/order Content-Type: application/json { diff --git a/spec/index.md b/spec/index.md index d0d006d..3053623 100644 --- a/spec/index.md +++ b/spec/index.md @@ -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` | | 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` | +| 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 -- Total: **31** +- Total: **32** - TODO: **1** -- DONE: **30** +- DONE: **31** diff --git a/spec/overview.md b/spec/overview.md index 61c182c..ac6014a 100644 --- a/spec/overview.md +++ b/spec/overview.md @@ -59,7 +59,7 @@ ### 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/init-payment` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse` +- `POST /api/v1/delivery/order` — принимает `InitPaymentRequest`, возвращает `InitPaymentResponse` - `POST /api/v1/delivery/tbank/notifications` — принимает `TBankPaymentNotification`, возвращает plain text `OK` при успешной обработке - Парсинг и валидация входных данных через Pydantic - Маппинг исключений сервиса в HTTP-ответы diff --git a/spec/tasks/028_add_postgresql_order_persistence.md b/spec/tasks/028_add_postgresql_order_persistence.md index 3a3cea7..cb25390 100644 --- a/spec/tasks/028_add_postgresql_order_persistence.md +++ b/spec/tasks/028_add_postgresql_order_persistence.md @@ -43,7 +43,7 @@ created: 2026-04-12 - Секция `postgres` присутствует в `_RequiredYamlSections`. - В `docker-compose.yml` добавлен сервис `postgres` и `app` зависит от него. - 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 - [ ] Создан модуль `app/adapters/postgres/` с engine/session factory. diff --git a/spec/tasks/029_add_tbank_payment_urls.md b/spec/tasks/029_add_tbank_payment_urls.md index 4483ee8..d2b2c3c 100644 --- a/spec/tasks/029_add_tbank_payment_urls.md +++ b/spec/tasks/029_add_tbank_payment_urls.md @@ -16,7 +16,7 @@ TBank Init API должен получать URLs для обработки webh - `NotificationURL` и `SuccessURL` MUST приходить из секции `tbank_payment` YAML-конфига. - TBank adapter MUST продолжать инкапсулировать HTTP-взаимодействие, auth token, retries, timeout, serialization и error handling. - 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. - Не добавлять новые endpoints. @@ -28,7 +28,7 @@ TBank Init API должен получать URLs для обработки webh - Auth token TBank формируется с учётом тех же полей payload, которые отправляются в Init API, включая `NotificationURL` и `SuccessURL`, если текущая реализация token generation строится по request payload. - Отсутствие `notification_url` или `success_url` в YAML-конфиге приводит к детерминированной ошибке валидации конфигурации. - `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 - [ ] В `app/config.py` добавлены поля `notification_url` и `success_url` в TBank payment config. diff --git a/spec/tasks/030_add_tbank_payment_notification_webhook.md b/spec/tasks/030_add_tbank_payment_notification_webhook.md index 4728874..1522f0d 100644 --- a/spec/tasks/030_add_tbank_payment_notification_webhook.md +++ b/spec/tasks/030_add_tbank_payment_notification_webhook.md @@ -1,7 +1,7 @@ --- id: 030 title: Add TBank payment notification webhook and CDEK order creation -status: TODO +status: DONE created: 2026-04-18 --- @@ -25,7 +25,7 @@ TBank Init API уже получает `NotificationURL`, а данные зая - 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. +- Не изменять public contract `POST /api/v1/delivery/order`, price flow и address suggestion flow. - Не добавлять новые payment providers, refund flow, recurring payments, ручной retry endpoint или endpoint чтения заявок. - Не изменять файлы в `spec/`. diff --git a/spec/tasks/031_validate_init_payment_price_with_cdek.md b/spec/tasks/031_validate_init_payment_price_with_cdek.md new file mode 100644 index 0000000..6ad0016 --- /dev/null +++ b/spec/tasks/031_validate_init_payment_price_with_cdek.md @@ -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` diff --git a/tests/adapters/delivery_providers/cdek/test_mapper.py b/tests/adapters/delivery_providers/cdek/test_mapper.py index f9d9eb3..090f8dd 100644 --- a/tests/adapters/delivery_providers/cdek/test_mapper.py +++ b/tests/adapters/delivery_providers/cdek/test_mapper.py @@ -10,6 +10,7 @@ def test_map_cdek_response_maps_all_tariffs_to_unified_model() -> None: "currency": "usd", "tariff_codes": [ { + "tariff_code": 7, "tariff_name": "Express", "delivery_sum": "1234.50", "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.delivery_days_min for price in result] == [2, 5] 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: diff --git a/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py b/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py new file mode 100644 index 0000000..7ad3742 --- /dev/null +++ b/tests/adapters/delivery_providers/cdek/test_payment_price_validation.py @@ -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())) diff --git a/tests/controllers/v1/test_delivery.py b/tests/controllers/v1/test_delivery.py index b8e41e4..fb4fdc9 100644 --- a/tests/controllers/v1/test_delivery.py +++ b/tests/controllers/v1/test_delivery.py @@ -183,6 +183,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( "currency": "RUB", "delivery_days_min": 2, "delivery_days_max": 3, + "tariff_code": None, }, { "provider": "stub-provider", @@ -191,6 +192,7 @@ def test_post_delivery_price_uses_registered_provider_in_default_dependency( "currency": "RUB", "delivery_days_min": 4, "delivery_days_max": 5, + "tariff_code": None, } ] assert second_response.json() == first_response.json() @@ -308,9 +310,9 @@ def test_post_delivery_price_filters_response_by_optional_parcel_type( ) -> None: provider = StubPriceProvider( response=[ - _make_price(service_name="Parcel locker", price="90.00"), - _make_price(service_name="Срочный документ", price="150.00"), - _make_price(service_name="DOCUMENT EXPRESS", price="200.00"), + _make_price(service_name="Parcel locker", price="90.00", provider="other"), + _make_price(service_name="Срочный документ", price="150.00", provider="other"), + _make_price(service_name="DOCUMENT EXPRESS", price="200.00", provider="other"), ] ) service = AggregatorService(providers=[provider]) diff --git a/tests/controllers/v1/test_init_payment.py b/tests/controllers/v1/test_init_payment.py index aea0583..d6ebf1b 100644 --- a/tests/controllers/v1/test_init_payment.py +++ b/tests/controllers/v1/test_init_payment.py @@ -85,7 +85,7 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=_valid_payload(), ) @@ -101,7 +101,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None: app = create_app() _install_service_override(app, service) invalid_payload = _valid_payload() - invalid_payload["tariff_code"] = 136 + invalid_payload["tariff_code"] = "not-a-number" async def run_request() -> httpx.Response: transport = httpx.ASGITransport(app=app) @@ -110,7 +110,7 @@ def test_post_init_payment_rejects_invalid_payload() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -134,7 +134,7 @@ def test_post_init_payment_rejects_missing_price() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -158,7 +158,7 @@ def test_post_init_payment_rejects_non_positive_price() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -182,7 +182,7 @@ def test_post_init_payment_rejects_non_integer_price() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -209,7 +209,7 @@ def test_post_init_payment_rejects_sender_company_field() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -236,7 +236,7 @@ def test_post_init_payment_rejects_sender_phones_field() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -263,7 +263,7 @@ def test_post_init_payment_rejects_recipient_company_field() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -290,7 +290,7 @@ def test_post_init_payment_rejects_recipient_phones_field() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", json=invalid_payload, ) @@ -314,7 +314,7 @@ def test_post_init_payment_accepts_request_without_services() -> None: transport=transport, base_url="http://testserver", ) 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()) @@ -338,7 +338,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", 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: service = StubAggregatorService( response=None, @@ -368,7 +399,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None: base_url="http://testserver", ) as client: return await client.post( - "/api/v1/delivery/init-payment", + "/api/v1/delivery/order", 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: service = StubAggregatorService(response=None) app = create_app() diff --git a/tests/domain/test_payment_price_validation.py b/tests/domain/test_payment_price_validation.py new file mode 100644 index 0000000..bd307ca --- /dev/null +++ b/tests/domain/test_payment_price_validation.py @@ -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) diff --git a/tests/domain/test_price.py b/tests/domain/test_price.py index 4f34994..822e16b 100644 --- a/tests/domain/test_price.py +++ b/tests/domain/test_price.py @@ -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"] +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: prices = [ _make_price(service_name="Документ"), diff --git a/tests/services/test_aggregator.py b/tests/services/test_aggregator.py index f399d00..b815b86 100644 --- a/tests/services/test_aggregator.py +++ b/tests/services/test_aggregator.py @@ -218,12 +218,12 @@ def test_get_all_prices_cache_hit_skips_provider_call() -> None: StubCache( forced_get_value=[ _make_price( - "cdek", + "other", "100.40", service_name="DOCUMENT EXPRESS", ).model_dump(mode="json"), _make_price( - "cdek", + "other", "200.40", service_name="Economy parcel", ).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, ) -> None: provider = StubProvider( - name="cdek", + name="other", response=[ - _make_price("cdek", "100.40", service_name="DOCUMENT EXPRESS"), - _make_price("cdek", "200.40", service_name="Economy parcel"), + _make_price("other", "100.40", service_name="DOCUMENT EXPRESS"), + _make_price("other", "200.40", service_name="Economy parcel"), ], ) service = AggregatorService( diff --git a/tests/services/test_init_payment.py b/tests/services/test_init_payment.py index 44fff7c..4455cc3 100644 --- a/tests/services/test_init_payment.py +++ b/tests/services/test_init_payment.py @@ -1,13 +1,16 @@ import asyncio +from decimal import Decimal import pytest +from app.adapters.delivery_providers.base import ProviderClientError, ProviderRequestError from app.adapters.tbank.base import ( TBankPaymentAdapterError, TBankPaymentRequestError, ) from app.repositories.order import OrderData from app.schemas.payment import InitPaymentRequest, InitPaymentResponse +from app.schemas.response import DeliveryPrice from app.services.aggregator import ( AggregatorService, InitPaymentUnavailableError, @@ -21,13 +24,17 @@ class StubPaymentAdapter: *, response: str | None = None, error: Exception | None = None, + events: list[str] | None = None, ) -> None: self._response = response self._error = error + self._events = events self.calls: list[tuple[str, int]] = [] async def create_payment_link(self, order_uuid: str, amount_kopecks: int) -> str: self.calls.append((order_uuid, amount_kopecks)) + if self._events is not None: + self._events.append("tbank") if self._error is not None: raise self._error if self._response is None: @@ -35,6 +42,31 @@ class StubPaymentAdapter: 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: def __init__(self, session: object) -> None: self._session = session @@ -105,30 +137,57 @@ def _make_init_payment_request(**overrides: object) -> InitPaymentRequest: 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() - adapter = StubPaymentAdapter(response="https://pay.test/payment/1") - service = AggregatorService(providers=[], payment_adapter=adapter) + events: list[str] = [] + 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)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") + assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] + assert events == ["cdek", "tbank"] def test_init_payment_persists_order_after_successful_payment_link() -> None: request = _make_init_payment_request() 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, ) result = asyncio.run(service.init_payment(request)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") + assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] 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: request = _make_init_payment_request() adapter = StubPaymentAdapter(response="https://pay.test/payment/1") + validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) order_repository = StubOrderRepository(error=RuntimeError("database down")) service = AggregatorService( providers=[], payment_adapter=adapter, + payment_price_validation_adapter=validation_adapter, order_repository=order_repository, ) result = asyncio.run(service.init_payment(request)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") + assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] 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: request = _make_init_payment_request() 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): asyncio.run(service.init_payment(request)) + assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] def test_init_payment_delegates_request_with_services_none_without_new_logic() -> None: request = _make_init_payment_request(services=None) 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)) assert result == InitPaymentResponse(payment_url="https://pay.test/payment/2") + assert validation_adapter.calls == [request] assert adapter.calls == [("order-uuid-1", 125000)] 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: request = _make_init_payment_request() 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): asyncio.run(service.init_payment(request)) + assert validation_adapter.calls == [request] 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): 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 == []