Добавлена валидация цены
This commit is contained in:
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
+56
-1
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user