Добавлен заказ накладной

This commit is contained in:
Раис Юсупалиев
2026-05-23 17:29:56 +03:00
parent 39cd5ddc7a
commit c494d50566
21 changed files with 1384 additions and 1126 deletions
@@ -0,0 +1,97 @@
"""Rework orders table to a single payload JSONB column."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
revision: str = "20260513_032"
down_revision: str | None = "20260412_028"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.drop_column("orders", "sender")
op.drop_column("orders", "recipient")
op.drop_column("orders", "from_location")
op.drop_column("orders", "to_location")
op.drop_column("orders", "packages")
op.drop_column("orders", "services")
op.drop_column("orders", "comment")
op.drop_column("orders", "delivery_type")
op.add_column(
"orders",
sa.Column(
"payload",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column("account_email", sa.String(length=320), nullable=False),
)
def downgrade() -> None:
op.drop_column("orders", "account_email")
op.drop_column("orders", "payload")
op.add_column(
"orders",
sa.Column("delivery_type", sa.Integer(), nullable=False),
)
op.add_column(
"orders",
sa.Column("comment", sa.String(length=1024), nullable=True),
)
op.add_column(
"orders",
sa.Column(
"services",
postgresql.JSONB(astext_type=sa.Text()),
nullable=True,
),
)
op.add_column(
"orders",
sa.Column(
"packages",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"to_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"from_location",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"recipient",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
op.add_column(
"orders",
sa.Column(
"sender",
postgresql.JSONB(astext_type=sa.Text()),
nullable=False,
),
)
@@ -0,0 +1,28 @@
"""Add CDEK waybill columns to orders table."""
from collections.abc import Sequence
from alembic import op
import sqlalchemy as sa
revision: str = "20260523_033"
down_revision: str | None = "20260513_032"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
op.add_column(
"orders",
sa.Column("cdek_waybill_uuid", sa.String(length=128), nullable=True),
)
op.add_column(
"orders",
sa.Column("cdek_waybill_url", sa.String(length=2048), nullable=True),
)
def downgrade() -> None:
op.drop_column("orders", "cdek_waybill_url")
op.drop_column("orders", "cdek_waybill_uuid")
+49 -25
View File
@@ -20,9 +20,13 @@ from app.adapters.delivery_providers.cdek.mapper import (
) )
from app.adapters.delivery_providers.cdek.order_mapper import ( from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderMappingError, CDEKOrderMappingError,
CDEKOrderRegistrationResult,
centimeters_string_to_int,
kilograms_string_to_grams,
map_cdek_existing_order_response, map_cdek_existing_order_response,
map_cdek_order_request, map_cdek_order_request,
map_cdek_order_response, map_cdek_order_response,
resolve_cdek_city_code,
) )
from app.cities import cities_map from app.cities import cities_map
from app.config import AdapterConfig from app.config import AdapterConfig
@@ -143,7 +147,9 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.") raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order(self, request: InitPaymentRequest) -> str: async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
payload = map_cdek_order_request(request) payload = map_cdek_order_request(request)
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
@@ -172,11 +178,17 @@ class CDEKClient:
) )
if 400 <= response.status_code < 500: if 400 <= response.status_code < 500:
existing_order_uuid = map_cdek_existing_order_response( response_payload = _response_json_or_none(response)
_response_json_or_none(response) existing_result = map_cdek_existing_order_response(response_payload)
if existing_result is not None:
return existing_result
log.warning(
"cdek_order_register_rejected",
status_code=response.status_code,
request_payload=payload,
response_payload=response_payload,
response_body=_response_text_or_none(response),
) )
if existing_order_uuid is not None:
return existing_order_uuid
raise CDEKRequestError( raise CDEKRequestError(
"CDEK order registration request was rejected with status " "CDEK order registration request was rejected with status "
f"{response.status_code}." f"{response.status_code}."
@@ -195,6 +207,12 @@ class CDEKClient:
"CDEK order registration payload must be a JSON object." "CDEK order registration payload must be a JSON object."
) )
log.info(
"cdek_order_register_raw_response",
status_code=response.status_code,
response_payload=raw_payload,
)
try: try:
return map_cdek_order_response(raw_payload) return map_cdek_order_response(raw_payload)
except CDEKOrderMappingError as exc: except CDEKOrderMappingError as exc:
@@ -234,25 +252,29 @@ class CDEKClient:
def _build_payment_price_payload( def _build_payment_price_payload(
request: InitPaymentRequest, request: InitPaymentRequest,
) -> dict[str, Any]: ) -> dict[str, Any]:
payload: dict[str, Any] = { try:
"type": request.type, from_code = resolve_cdek_city_code(request.sender_address.city_id)
"from_location": request.from_location.model_dump(mode="python"), to_code = resolve_cdek_city_code(request.receiver_address.city_id)
"to_location": request.to_location.model_dump(mode="python"), weight_grams = kilograms_string_to_grams(request.system_data.weight)
"packages": [ except CDEKOrderMappingError as exc:
{ raise CDEKRequestError(str(exc)) from exc
"weight": package.weight * 1000,
"length": package.length, package: dict[str, Any] = {"weight": weight_grams}
"width": package.width, dimensions = request.system_data.dimensions
"height": package.height, if request.system_data.parcel_type == "parcel" and dimensions is not None:
try:
package["length"] = centimeters_string_to_int(dimensions.length, "length")
package["width"] = centimeters_string_to_int(dimensions.width, "width")
package["height"] = centimeters_string_to_int(dimensions.height, "height")
except CDEKOrderMappingError as exc:
raise CDEKRequestError(str(exc)) from exc
return {
"type": 2,
"from_location": {"code": from_code},
"to_location": {"code": to_code},
"packages": [package],
} }
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:
@@ -327,14 +349,16 @@ class CDEKProvider(DeliveryProvider):
try: try:
return map_cdek_response_for_tariff_code( return map_cdek_response_for_tariff_code(
raw_payload, raw_payload,
tariff_code=request.tariff_code, tariff_code=request.system_data.tariff.tariff_code,
) )
except CDEKMappingError as exc: except CDEKMappingError as exc:
raise CDEKClientError( raise CDEKClientError(
"CDEK payment price validation response payload is invalid." "CDEK payment price validation response payload is invalid."
) from exc ) from exc
async def register_order(self, request: InitPaymentRequest) -> str: async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
return await self._client.register_order(request) return await self._client.register_order(request)
@@ -1,11 +1,16 @@
"""CDEK order registration payload mappers.""" """CDEK order registration payload mappers."""
from dataclasses import dataclass
from decimal import ROUND_HALF_UP, Decimal, InvalidOperation
from typing import Any from typing import Any
from app.cities import cities_map
from app.schemas.payment import ( from app.schemas.payment import (
Address,
Contact,
Dimensions,
InitPaymentRequest, InitPaymentRequest,
PaymentPackage, SystemData,
PaymentParty,
) )
@@ -13,27 +18,35 @@ class CDEKOrderMappingError(ValueError):
"""Raised when CDEK order payload cannot be mapped.""" """Raised when CDEK order payload cannot be mapped."""
_CDEK_WAYBILL_PRINT_TYPE = "WAYBILL"
_CDEK_WAYBILL_RELATED_ENTITY_TYPE = "waybill"
@dataclass(frozen=True)
class CDEKOrderRegistrationResult:
order_uuid: str
waybill_uuid: str | None
waybill_url: str | None
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]: def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
payload: dict[str, Any] = { payload: dict[str, Any] = {
"number": request.order_uuid, "number": request.order_uuid,
"type": request.type, "type": 2,
"tariff_code": request.tariff_code, "tariff_code": request.system_data.tariff.tariff_code,
"sender": _map_order_party(request.sender), "print": _CDEK_WAYBILL_PRINT_TYPE,
"recipient": _map_order_party(request.recipient), "sender": _map_party(request.sender_contact),
"from_location": request.from_location.model_dump(mode="python"), "recipient": _map_party(request.receiver_contact),
"to_location": request.to_location.model_dump(mode="python"), "from_location": _map_location(request.sender_address),
"packages": [_map_order_package(package) for package in request.packages], "to_location": _map_location(request.receiver_address),
"packages": [_map_package(request)],
} }
if request.comment is not None: if request.content.description:
payload["comment"] = request.comment payload["comment"] = request.content.description
if request.services is not None:
payload["services"] = [
service.model_dump(mode="python") for service in request.services
]
return payload return payload
def map_cdek_order_response(payload: dict[str, Any]) -> str: def map_cdek_order_response(payload: dict[str, Any]) -> CDEKOrderRegistrationResult:
entity = payload.get("entity") entity = payload.get("entity")
if not isinstance(entity, dict): if not isinstance(entity, dict):
raise CDEKOrderMappingError("CDEK order response must include entity object.") raise CDEKOrderMappingError("CDEK order response must include entity object.")
@@ -42,13 +55,20 @@ def map_cdek_order_response(payload: dict[str, Any]) -> str:
if not isinstance(order_uuid, str) or not order_uuid: if not isinstance(order_uuid, str) or not order_uuid:
raise CDEKOrderMappingError("CDEK order response must include entity.uuid.") raise CDEKOrderMappingError("CDEK order response must include entity.uuid.")
return order_uuid waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
_CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"}) _CDEK_DUPLICATE_ORDER_ERROR_CODES = frozenset({"v2_entity_already_exists"})
def map_cdek_existing_order_response(payload: object) -> str | None: def map_cdek_existing_order_response(
payload: object,
) -> CDEKOrderRegistrationResult | None:
if not isinstance(payload, dict): if not isinstance(payload, dict):
return None return None
@@ -58,21 +78,156 @@ def map_cdek_existing_order_response(payload: object) -> str | None:
try: try:
return map_cdek_order_response(payload) return map_cdek_order_response(payload)
except CDEKOrderMappingError: except CDEKOrderMappingError:
return _find_first_uuid(payload) order_uuid = _find_first_uuid(payload)
if order_uuid is None:
return None
waybill_uuid, waybill_url = _extract_waybill(payload)
return CDEKOrderRegistrationResult(
order_uuid=order_uuid,
waybill_uuid=waybill_uuid,
waybill_url=waybill_url,
)
def _map_order_party(party: PaymentParty) -> dict[str, Any]: def _extract_waybill(payload: dict[str, Any]) -> tuple[str | None, str | None]:
related_entities = payload.get("related_entities")
if not isinstance(related_entities, list):
return None, None
for entry in related_entities:
if not isinstance(entry, dict):
continue
if entry.get("type") != _CDEK_WAYBILL_RELATED_ENTITY_TYPE:
continue
uuid_value = entry.get("uuid")
url_value = entry.get("url")
waybill_uuid = uuid_value if isinstance(uuid_value, str) and uuid_value else None
waybill_url = url_value if isinstance(url_value, str) and url_value else None
if waybill_uuid is None and waybill_url is None:
continue
return waybill_uuid, waybill_url
return None, None
def compose_address_line(address: Address) -> str:
"""Build a CDEK address line from structured fields."""
parts = [address.city, address.street, address.house]
line = ", ".join(part for part in parts if part)
if address.apartment:
line = f"{line}, кв. {address.apartment}"
return line
def resolve_cdek_city_code(city_id: int) -> int:
"""Resolve CDEK city code from cities_map by city identifier."""
city_entry = cities_map.get(str(city_id))
if not isinstance(city_entry, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
cdek_data = city_entry.get("cdek")
if not isinstance(cdek_data, dict):
raise CDEKOrderMappingError(
f"CDEK city mapping is not configured for city id {city_id}."
)
raw_city_code = cdek_data.get("code")
if raw_city_code is None or isinstance(raw_city_code, bool):
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
)
try:
return int(raw_city_code)
except (TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"CDEK city code is invalid for city id {city_id}."
) from exc
_GRAMS_IN_KILOGRAM = Decimal(1000)
_INTEGER_QUANTIZER = Decimal(1)
def kilograms_string_to_grams(value: str) -> int:
kilograms = _parse_positive_decimal(value, field_name="weight")
return int(
(kilograms * _GRAMS_IN_KILOGRAM).quantize(
_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP
)
)
def centimeters_string_to_int(value: str, field_name: str) -> int:
measurement = _parse_positive_decimal(value, field_name=field_name)
return int(measurement.quantize(_INTEGER_QUANTIZER, rounding=ROUND_HALF_UP))
def _parse_positive_decimal(value: str, *, field_name: str) -> Decimal:
try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
raise CDEKOrderMappingError(
f"{field_name} is not a valid decimal: {value!r}."
) from exc
if not parsed.is_finite() or parsed <= 0:
raise CDEKOrderMappingError(
f"{field_name} must be a positive decimal: {value!r}."
)
return parsed
def _map_party(contact: Contact) -> dict[str, Any]:
phone: dict[str, Any] = {"number": contact.phone}
if contact.phone_ext:
phone["additional"] = contact.phone_ext
party: dict[str, Any] = {
"name": contact.full_name,
"phones": [phone],
}
if contact.email:
party["email"] = contact.email
if contact.is_company:
party["contragent_type"] = "LEGAL_ENTITY"
party["company"] = contact.company_name
party["inn"] = contact.inn
party["kpp"] = contact.kpp
else:
party["company"] = contact.full_name
return party
def _map_location(address: Address) -> dict[str, Any]:
return { return {
"name": party.name, "code": resolve_cdek_city_code(address.city_id),
"email": party.email, "address": compose_address_line(address),
"phones": [party.phone.model_dump(mode="python")], "postal_code": address.zip,
} }
def _map_order_package(package: PaymentPackage) -> dict[str, Any]: def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
payload = package.model_dump(mode="python", exclude_none=True) system_data: SystemData = request.system_data
payload["weight"] = package.weight * 1000 weight_grams = kilograms_string_to_grams(request.system_data.weight)
return payload description = request.content.description or request.order_uuid
package: dict[str, Any] = {
"number": request.order_uuid,
"weight": weight_grams,
"comment": description,
}
if system_data.parcel_type == "parcel" and system_data.dimensions is not None:
package.update(_map_dimensions(system_data.dimensions))
return package
def _map_dimensions(dimensions: Dimensions) -> dict[str, int]:
return {
"length": centimeters_string_to_int(dimensions.length, "length"),
"width": centimeters_string_to_int(dimensions.width, "width"),
"height": centimeters_string_to_int(dimensions.height, "height"),
}
def _contains_cdek_duplicate_error_code(payload: object) -> bool: def _contains_cdek_duplicate_error_code(payload: object) -> bool:
+4 -20
View File
@@ -32,33 +32,17 @@ class Order(Base):
order_uuid: Mapped[str] = mapped_column(String(128), nullable=False) order_uuid: Mapped[str] = mapped_column(String(128), nullable=False)
payment_url: Mapped[str] = mapped_column(String(2048), nullable=False) payment_url: Mapped[str] = mapped_column(String(2048), nullable=False)
price: Mapped[int] = mapped_column(Integer, nullable=False) price: Mapped[int] = mapped_column(Integer, nullable=False)
delivery_type: Mapped[int] = mapped_column(Integer, nullable=False)
tariff_code: Mapped[int] = mapped_column(Integer, nullable=False) tariff_code: Mapped[int] = mapped_column(Integer, nullable=False)
sender: Mapped[dict[str, Any]] = mapped_column(_json_payload_type(), nullable=False) account_email: Mapped[str] = mapped_column(String(320), nullable=False)
recipient: Mapped[dict[str, Any]] = mapped_column( payload: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(), _json_payload_type(),
nullable=False, nullable=False,
) )
from_location: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
to_location: Mapped[dict[str, Any]] = mapped_column(
_json_payload_type(),
nullable=False,
)
packages: Mapped[list[dict[str, Any]]] = mapped_column(
_json_payload_type(),
nullable=False,
)
services: Mapped[list[dict[str, Any]] | None] = mapped_column(
_json_payload_type(),
nullable=True,
)
comment: Mapped[str | None] = mapped_column(String(1024), nullable=True)
payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True) payment_status: Mapped[str | None] = mapped_column(String(64), nullable=True)
tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True) tbank_payment_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True) cdek_order_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_uuid: Mapped[str | None] = mapped_column(String(128), nullable=True)
cdek_waybill_url: Mapped[str | None] = mapped_column(String(2048), nullable=True)
created_at: Mapped[datetime] = mapped_column( created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
server_default=func.now(), server_default=func.now(),
+8 -16
View File
@@ -15,15 +15,9 @@ class OrderData:
order_uuid: str order_uuid: str
payment_url: str payment_url: str
price: int price: int
delivery_type: int
tariff_code: int tariff_code: int
sender: dict[str, Any] account_email: str
recipient: dict[str, Any] payload: dict[str, Any]
from_location: dict[str, Any]
to_location: dict[str, Any]
packages: list[dict[str, Any]]
services: list[dict[str, Any]] | None
comment: str | None
class OrderRepository: class OrderRepository:
@@ -38,15 +32,9 @@ class OrderRepository:
order_uuid=order_data.order_uuid, order_uuid=order_data.order_uuid,
payment_url=order_data.payment_url, payment_url=order_data.payment_url,
price=order_data.price, price=order_data.price,
delivery_type=order_data.delivery_type,
tariff_code=order_data.tariff_code, tariff_code=order_data.tariff_code,
sender=order_data.sender, account_email=order_data.account_email,
recipient=order_data.recipient, payload=order_data.payload,
from_location=order_data.from_location,
to_location=order_data.to_location,
packages=order_data.packages,
services=order_data.services,
comment=order_data.comment,
) )
session.add(order) session.add(order)
await session.flush() await session.flush()
@@ -84,11 +72,15 @@ class OrderRepository:
session: AsyncSession, session: AsyncSession,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> Order | None: ) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid) order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None: if order is None:
return None return None
order.cdek_order_uuid = cdek_order_uuid order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
await session.flush() await session.flush()
return order return order
+105 -44
View File
@@ -1,61 +1,122 @@
"""Schemas for delivery payment initialization.""" """Schemas for delivery payment initialization."""
from datetime import datetime
from decimal import Decimal, InvalidOperation
from typing import Literal from typing import Literal
from pydantic import BaseModel, ConfigDict, Field, model_validator from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
from pydantic.alias_generators import to_camel
class PaymentPhone(BaseModel): def _validate_positive_decimal_string(value: str) -> str:
number: str = Field(min_length=1) try:
parsed = Decimal(value)
except (InvalidOperation, TypeError, ValueError) as exc:
class PaymentParty(BaseModel): raise ValueError("must be a positive decimal number") from exc
model_config = ConfigDict(extra="forbid") if not parsed.is_finite() or parsed <= 0:
raise ValueError("must be a positive decimal number")
name: str = Field(min_length=1)
email: str = Field(min_length=1)
phone: PaymentPhone
@model_validator(mode="before")
@classmethod
def reject_company_field(cls, value: object) -> object:
if isinstance(value, dict) and "company" in value:
raise ValueError("company is not allowed")
return value return value
class DeliveryLocation(BaseModel): class _CamelModel(BaseModel):
address: str = Field(min_length=1) model_config = ConfigDict(
alias_generator=to_camel,
populate_by_name=False,
extra="forbid",
)
class Address(_CamelModel):
city_id: int = Field(gt=0, strict=True)
city: str = Field(min_length=1) city: str = Field(min_length=1)
country_code: str = Field(min_length=2, max_length=2) street: str = Field(min_length=1)
house: str = Field(min_length=1)
apartment: str | None = None
class PaymentService(BaseModel): zip: str = Field(min_length=1)
code: str = Field(min_length=1)
parameter: str = Field(min_length=1)
class PaymentPackage(BaseModel):
number: str = Field(min_length=1)
weight: int = Field(gt=0)
length: int = Field(gt=0)
width: int = Field(gt=0)
height: int = Field(gt=0)
comment: str | None = None comment: str | None = None
class InitPaymentRequest(BaseModel): class Contact(_CamelModel):
order_uuid: str = Field(min_length=1) full_name: str = Field(min_length=1)
email: str | None = None
phone: str = Field(min_length=1)
phone_ext: str | None = None
is_company: bool
company_name: str | None = None
inn: str | None = None
kpp: str | None = None
@model_validator(mode="after")
def _validate_company_requisites(self) -> "Contact":
if self.is_company:
missing = [
name
for name, value in (
("companyName", self.company_name),
("inn", self.inn),
("kpp", self.kpp),
)
if value is None or value == ""
]
if missing:
raise ValueError(
f"{', '.join(missing)} are required when isCompany is true"
)
return self
class Content(_CamelModel):
description: str | None = None
class SystemDataTariff(_CamelModel):
provider: str = Field(min_length=1)
service_name: 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] delivery_days_min: int = Field(ge=0, strict=True)
tariff_code: int delivery_days_max: int = Field(ge=0, strict=True)
comment: str | None = None tariff_code: int = Field(gt=0, strict=True)
sender: PaymentParty
recipient: PaymentParty
from_location: DeliveryLocation class Dimensions(_CamelModel):
to_location: DeliveryLocation length: str = Field(min_length=1)
services: list[PaymentService] | None = Field(default=None, min_length=1) width: str = Field(min_length=1)
packages: list[PaymentPackage] = Field(min_length=1) height: str = Field(min_length=1)
_validate_dimensions = field_validator("length", "width", "height")(
_validate_positive_decimal_string
)
class SystemData(_CamelModel):
tariff: SystemDataTariff
parcel_type: Literal["doc", "parcel"]
doc_packaging: Literal["envelope", "bag"] | None = None
weight: str = Field(min_length=1)
dimensions: Dimensions | None = None
_validate_weight = field_validator("weight")(_validate_positive_decimal_string)
@model_validator(mode="after")
def _validate_dimensions_for_parcel_type(self) -> "SystemData":
if self.parcel_type == "parcel" and self.dimensions is None:
raise ValueError("dimensions are required when parcelType is 'parcel'")
if self.parcel_type == "doc" and self.doc_packaging is None:
raise ValueError("docPackaging is required when parcelType is 'doc'")
return self
class InitPaymentRequest(_CamelModel):
order_uuid: str = Field(min_length=1)
sender_address: Address
sender_contact: Contact
receiver_address: Address
receiver_contact: Contact
content: Content
pickup_date: datetime
delivery_date: datetime | None = None
account_email: str = Field(min_length=1)
system_data: SystemData
class InitPaymentResponse(BaseModel): class InitPaymentResponse(BaseModel):
+39 -40
View File
@@ -20,6 +20,9 @@ from app.adapters.delivery_providers.base import (
ProviderClientError, ProviderClientError,
ProviderRequestError, ProviderRequestError,
) )
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import ( from app.adapters.tbank.base import (
TBankPaymentAdapterError, TBankPaymentAdapterError,
TBankPaymentNotificationTokenError, TBankPaymentNotificationTokenError,
@@ -109,7 +112,9 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol): class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(self, request: InitPaymentRequest) -> str: ... async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult: ...
class OrderRepositoryProtocol(Protocol): class OrderRepositoryProtocol(Protocol):
@@ -136,6 +141,8 @@ class OrderRepositoryProtocol(Protocol):
session: object, session: object,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> object | None: ... ) -> object | None: ...
@@ -259,7 +266,7 @@ class AggregatorService:
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,
amount_kopecks=request.price, amount_kopecks=request.system_data.tariff.price,
) )
except TBankPaymentRequestError as exc: except TBankPaymentRequestError as exc:
logger.exception( logger.exception(
@@ -294,6 +301,8 @@ class AggregatorService:
"Payment price validation adapter is not configured." "Payment price validation adapter is not configured."
) )
tariff_code = request.system_data.tariff.tariff_code
requested_price = request.system_data.tariff.price
try: try:
provider_price = await self._payment_price_validation_adapter.get_payment_price( provider_price = await self._payment_price_validation_adapter.get_payment_price(
request request
@@ -302,8 +311,8 @@ class AggregatorService:
logger.warning( logger.warning(
"init_payment_price_validation_request_rejected", "init_payment_price_validation_request_rejected",
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
tariff_code=request.tariff_code, tariff_code=tariff_code,
requested_price_kopecks=request.price, requested_price_kopecks=requested_price,
error=str(exc), error=str(exc),
) )
raise InvalidInitPaymentRequestError( raise InvalidInitPaymentRequestError(
@@ -313,8 +322,8 @@ class AggregatorService:
logger.warning( logger.warning(
"init_payment_price_validation_unavailable", "init_payment_price_validation_unavailable",
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
tariff_code=request.tariff_code, tariff_code=tariff_code,
requested_price_kopecks=request.price, requested_price_kopecks=requested_price,
error=str(exc), error=str(exc),
) )
raise InitPaymentUnavailableError( raise InitPaymentUnavailableError(
@@ -324,8 +333,8 @@ class AggregatorService:
logger.exception( logger.exception(
"init_payment_price_validation_unexpected_error", "init_payment_price_validation_unexpected_error",
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
tariff_code=request.tariff_code, tariff_code=tariff_code,
requested_price_kopecks=request.price, requested_price_kopecks=requested_price,
) )
raise InitPaymentUnavailableError( raise InitPaymentUnavailableError(
"Payment price validation is temporarily unavailable." "Payment price validation is temporarily unavailable."
@@ -335,8 +344,8 @@ class AggregatorService:
logger.warning( logger.warning(
"init_payment_price_validation_tariff_not_found", "init_payment_price_validation_tariff_not_found",
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
tariff_code=request.tariff_code, tariff_code=tariff_code,
requested_price_kopecks=request.price, requested_price_kopecks=requested_price,
) )
raise InvalidInitPaymentRequestError( raise InvalidInitPaymentRequestError(
"CDEK did not return the requested tariff for payment validation." "CDEK did not return the requested tariff for payment validation."
@@ -347,15 +356,15 @@ class AggregatorService:
price_multiplier=self._provider_price_multiplier, price_multiplier=self._provider_price_multiplier,
) )
if not is_init_payment_price_valid( if not is_init_payment_price_valid(
request.price, requested_price,
provider_price, provider_price,
price_multiplier=self._provider_price_multiplier, price_multiplier=self._provider_price_multiplier,
): ):
logger.warning( logger.warning(
"init_payment_price_mismatch", "init_payment_price_mismatch",
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
tariff_code=request.tariff_code, tariff_code=tariff_code,
requested_price_kopecks=request.price, requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks, expected_price_kopecks=expected_amount_kopecks,
provider_currency=getattr(provider_price, "currency", None), provider_currency=getattr(provider_price, "currency", None),
provider_price=str(getattr(provider_price, "price", None)), provider_price=str(getattr(provider_price, "price", None)),
@@ -398,10 +407,12 @@ class AggregatorService:
if existing_cdek_order_uuid: if existing_cdek_order_uuid:
return "OK" return "OK"
cdek_order_uuid = await self._register_cdek_order(order) registration_result = await self._register_cdek_order(order)
await self._save_cdek_order_uuid( await self._save_cdek_order_uuid(
order_uuid=notification.OrderId, order_uuid=notification.OrderId,
cdek_order_uuid=cdek_order_uuid, cdek_order_uuid=registration_result.order_uuid,
cdek_waybill_uuid=registration_result.waybill_uuid,
cdek_waybill_url=registration_result.waybill_url,
) )
return "OK" return "OK"
@@ -456,7 +467,9 @@ class AggregatorService:
"TBank payment notification order update failed." "TBank payment notification order update failed."
) from exc ) from exc
async def _register_cdek_order(self, order: object) -> str: async def _register_cdek_order(
self, order: object
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None: if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError( raise TBankPaymentNotificationProcessingError(
"CDEK order registration adapter is not configured." "CDEK order registration adapter is not configured."
@@ -479,6 +492,8 @@ class AggregatorService:
*, *,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, cdek_order_uuid: str,
cdek_waybill_uuid: str | None,
cdek_waybill_url: str | None,
) -> None: ) -> None:
if self._order_repository is None: if self._order_repository is None:
raise TBankPaymentNotificationProcessingError( raise TBankPaymentNotificationProcessingError(
@@ -491,6 +506,8 @@ class AggregatorService:
session, session,
order_uuid, order_uuid,
cdek_order_uuid, cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
) )
if order is None: if order is None:
logger.warning( logger.warning(
@@ -540,37 +557,19 @@ class AggregatorService:
request: InitPaymentRequest, request: InitPaymentRequest,
payment_url: str, payment_url: str,
) -> OrderData: ) -> OrderData:
payload = request.model_dump(mode="json")
return OrderData( return OrderData(
order_uuid=request.order_uuid, order_uuid=request.order_uuid,
payment_url=payment_url, payment_url=payment_url,
price=request.price, price=request.system_data.tariff.price,
delivery_type=request.type, tariff_code=request.system_data.tariff.tariff_code,
tariff_code=request.tariff_code, account_email=request.account_email,
sender=payload["sender"], payload=request.model_dump(mode="json", by_alias=True),
recipient=payload["recipient"],
from_location=payload["from_location"],
to_location=payload["to_location"],
packages=payload["packages"],
services=payload["services"],
comment=request.comment,
) )
@staticmethod @staticmethod
def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest: def _to_init_payment_request_from_order(order: object) -> InitPaymentRequest:
return InitPaymentRequest( payload = getattr(order, "payload")
order_uuid=getattr(order, "order_uuid"), return InitPaymentRequest.model_validate(payload)
price=getattr(order, "price"),
type=getattr(order, "delivery_type"),
tariff_code=getattr(order, "tariff_code"),
comment=getattr(order, "comment"),
sender=getattr(order, "sender"),
recipient=getattr(order, "recipient"),
from_location=getattr(order, "from_location"),
to_location=getattr(order, "to_location"),
services=getattr(order, "services"),
packages=getattr(order, "packages"),
)
async def _get_provider_prices( async def _get_provider_prices(
self, self,
+89 -34
View File
@@ -17,45 +17,69 @@ POST http://localhost:8000/api/v1/delivery/order
Content-Type: application/json Content-Type: application/json
{ {
"order_uuid": "order-uuid-1", "orderUuid": "order-uuid-1",
"price": 125000, "senderAddress": {
"type": 2, "cityId": 1,
"tariff_code": 535, "city": "Дубай",
"comment": "Тестовая оплата доставки", "street": "Sheikh Zayed Road",
"sender": { "house": "10",
"name": "Петр Петров", "apartment": "201",
"zip": "12345",
"comment": "Домофон 12"
},
"senderContact": {
"fullName": "Петр Петров",
"email": "sender@example.com", "email": "sender@example.com",
"phone": { "phone": "+79009876543",
"number": "+79009876543" "phoneExt": null,
} "isCompany": false,
"companyName": null,
"inn": null,
"kpp": null
}, },
"recipient": { "receiverAddress": {
"name": "Иван Иванов", "cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": null,
"zip": "54321",
"comment": null
},
"receiverContact": {
"fullName": "Иван Иванов",
"email": "ivan@example.com", "email": "ivan@example.com",
"phone": { "phone": "+79001234567",
"number": "+79001234567" "phoneExt": "101",
"isCompany": false,
"companyName": null,
"inn": null,
"kpp": null
},
"content": {
"description": "Наушники"
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535
},
"parcelType": "parcel",
"docPackaging": null,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10"
} }
},
"from_location": {
"address": "ул. Ленина, 1",
"city": "Москва",
"country_code": "RU"
},
"to_location": {
"address": "ул. Пушкина, 10",
"city": "Новосибирск",
"country_code": "RU"
},
"packages": [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Упаковка 1"
} }
]
} }
> {% > {%
@@ -66,3 +90,34 @@ Content-Type: application/json
GET {{base_url}}/v2/calculator/alltariffs GET {{base_url}}/v2/calculator/alltariffs
Authorization: Bearer {{auth_token}} Authorization: Bearer {{auth_token}}
X-User-Lang: rus X-User-Lang: rus
### 6. Формирование накладной по заказу
POST {{base_url}}/v2/print/orders
Authorization: Bearer {{auth_token}}
Content-Type: application/json
{
"orders": [
{
"order_uuid": "abd93f2d-7c5a-4820-9fdd-cab39aadbb1f"
}
],
"copy_count": 1
}
### 7. Получение накладной по UUID
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da
Authorization: Bearer {{auth_token}}
### 8. Просмотр заказа CDEK по UUID (с related_entities)
GET {{base_url}}/v2/orders/1e2282ba-529e-4ee7-9d82-2d7837145755
Authorization: Bearer {{auth_token}}
### 9. Скачивание готовой квитанции (PDF)
# Подставь WAYBILL_UUID из related_entities[type=waybill] (### 8).
# Файл сохраняется рядом с http-client.http как waybill.pdf.
GET {{base_url}}/v2/print/orders/31ff53f5-4c01-43aa-afa2-b7efdf0c06da.pdf
Authorization: Bearer {{auth_token}}
Accept: application/pdf
>>! waybill.pdf
+3 -2
View File
@@ -38,9 +38,10 @@
| 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 | DONE | 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` | | 031 | TODO | 2026-04-18 | Validate init-payment price with CDEK tariff | `spec/tasks/031_validate_init_payment_price_with_cdek.md` |
| 032 | TODO | 2026-05-13 | Rework init-payment contract to camelCase and structured address/contact | `spec/tasks/032_rework_init_payment_contract_camelcase.md` |
## Summary ## Summary
- Total: **32** - Total: **33**
- TODO: **1** - TODO: **2**
- DONE: **31** - DONE: **31**
+59 -27
View File
@@ -119,7 +119,15 @@
- Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case - Каждый адаптер владеет своей конфигурацией; наружу экспонирует только service-facing methods, необходимые соответствующему use-case
- Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API - Для расчёта тарифа CDEK adapter принимает city identifiers из `DeliveryCalculationRequest`, находит запись в `cities_map`, берёт `cdek.code` и передаёт его в CDEK API
Для CDEK order contract mapper принимает `InitPaymentRequest`, сериализует `sender.phone` и `recipient.phone` в provider payload `phones` с одним элементом, не отправляет `services` при отсутствии значения, конвертирует `packages[*].weight` из килограммов в граммы и возвращает `entity.uuid` строкой. Для CDEK order contract mapper принимает новый `InitPaymentRequest` и собирает provider payload:
- `number = orderUuid`, `type = 2`, `tariff_code = systemData.tariff.tariffCode`.
- `sender`/`recipient` маппятся из `senderContact`/`receiverContact`: `name = fullName`, `email`, `phones = [{number, additional: phoneExt}]`. При `isCompany=true` добавляются `contragent_type="LEGAL_ENTITY"`, `company`, `inn`, `kpp`.
- `from_location`/`to_location` собираются из `senderAddress`/`receiverAddress`: `code` берётся из `cities_map` по `cityId`, `address` склеивается строкой `"{city}, {street}, {house}, кв. {apartment}"` (хвост `кв.` опускается при пустом `apartment`), `postal_code = zip`.
- `packages[0]` содержит `number = orderUuid`, `weight = round(float(systemData.weight) * 1000)` в граммах; при `parcelType='parcel'` добавляются `length`, `width`, `height` из `systemData.dimensions`; при `parcelType='doc'` габариты не передаются.
- `packages[0].items[0]` пробрасывает `content.description` как `name`.
- `shipment_point.date = pickupDate.date()`, `delivery_point.date = deliveryDate.date()` (если задан).
- Поле верхнего уровня `comment` собирается из `content.description` (если есть).
- Возвращает `entity.uuid` строкой.
### Adapter (`app/adapters/tbank`) ### Adapter (`app/adapters/tbank`)
- `base.py` — исключения TBank payment adapter - `base.py` — исключения TBank payment adapter
@@ -203,36 +211,60 @@ postal_code: str | None
``` ```
### Входная: `InitPaymentRequest` ### Входная: `InitPaymentRequest`
Контракт ручки `/api/v1/delivery/order` использует camelCase в JSON; Pydantic-
модели хранят snake_case поля и принимают входной JSON через alias-generator.
``` ```
order_uuid: str orderUuid: str
price: int senderAddress:
type: Literal[2] cityId: int
tariff_code: Literal[535] city: str
street: str
house: str
apartment: str | None
zip: str
comment: str | None comment: str | None
sender: senderContact:
name: str fullName: str
email: str email: str | None
phone: {number: str} phone: str
recipient: phoneExt: str | None
name: str isCompany: bool
email: str companyName: str | None # обязателен при isCompany=true
phone: {number: str} inn: str | None # обязателен при isCompany=true
from_location: kpp: str | None # обязателен при isCompany=true
address: str receiverAddress: <структура senderAddress>
city: str receiverContact: <структура senderContact>
country_code: str content:
to_location: description: str | None
address: str pickupDate: datetime # ISO 8601
city: str deliveryDate: datetime | None # ISO 8601
country_code: str accountEmail: str
services: list[{code: str, parameter: str}] | None systemData:
packages: list[{number: str, weight: int, length: int, width: int, height: int, comment: str | None}] tariff:
provider: str
serviceName: str
price: int # копейки
deliveryDaysMin: int
deliveryDaysMax: int
tariffCode: int
parcelType: Literal[doc, parcel]
docPackaging: Literal[envelope, bag] | None # для doc
weight: str
dimensions: # null/отсутствует для doc
length: str
width: str
height: str
``` ```
`price` задаётся в копейках, является обязательным целым числом и должен быть больше 0. `systemData.tariff.price` задаётся в копейках, является обязательным целым
`from_location.address` и `to_location.address` должны содержать точные значения адреса, выбранные клиентом; payment flow не выполняет address suggestion lookup. числом и должен быть больше 0; backend использует его как сумму платежа
`sender.phone` и `recipient.phone` представляют единственный телефон для соответствующей стороны; передача нескольких телефонов во входном API не поддерживается. TBank без пересчёта.
`packages[*].weight` в `InitPaymentRequest` задаётся в килограммах; CDEK order mapper конвертирует его в граммы для provider payload. `senderAddress.cityId` и `receiverAddress.cityId` используют общий справочник
`cities_map` (тот же идентификатор, что и в `DeliveryCalculationRequest`).
Для `parcelType='doc'` `systemData.dimensions` отсутствует или равен `null`,
для `parcelType='parcel'` — обязателен.
При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
`pickupDate` и `deliveryDate` принимаются и сохраняются как ISO datetime.
### Выходная: `InitPaymentResponse` ### Выходная: `InitPaymentResponse`
``` ```
@@ -0,0 +1,102 @@
---
id: 032
title: Rework init-payment contract to camelCase and structured address/contact
status: TODO
created: 2026-05-13
---
## Context
Фронт переходит на новый контракт ручки `POST /api/v1/delivery/order`. В нём
адрес/контакт структурированы по-новому, появились флаг юр-лица и реквизиты
(`isCompany`/`companyName`/`inn`/`kpp`/`phoneExt`), описание груза и вес
(`content.description`), даты `pickupDate`/`deliveryDate`,
`accountEmail`, блок `systemData` с зафиксированным тарифом, parcelType,
docPackaging и dimensions. Поле наименования — camelCase. Это breaking change
без обратной совместимости.
## Goal
Привести бэкенд к новому контракту `/api/v1/delivery/order`, сохранив текущий
flow «валидация цены через CDEK → создание payment URL в TBank → persist в
PostgreSQL → возврат `payment_url`; webhook `CONFIRMED` → регистрация заказа в
CDEK».
## Constraints
- camelCase в API; внутренние имена остаются Python-friendly через Pydantic
alias-generator. JSON принимается ТОЛЬКО в camelCase.
- `orderUuid` и `systemData.tariff.tariffCode` приходят от фронта.
- `systemData.tariff.price` — итоговая сумма в копейках; backend использует её
как `amount_kopecks` для TBank без пересчёта.
- Расширить `senderAddress` и `receiverAddress` обязательным полем `cityId: int`
(то же, что в `/price`), чтобы резолвить CDEK city code из `cities_map`.
- При `isCompany=true` поля `companyName`, `inn`, `kpp` обязательны.
- При `parcelType='doc'` `dimensions` принимает значение `null`; CDEK packages
отправляются без length/width/height.
- При `parcelType='parcel'` `dimensions` обязателен.
- Адрес для CDEK собирается строкой `"{city}, {street}, {house}, кв. {apartment}"`;
если `apartment` пустой/отсутствует — без хвоста `, кв. ...`.
- `pickupDate` → CDEK `shipment_point.date`; `deliveryDate` → CDEK
`delivery_point.date` (если задан).
- `content.description` пробрасывается в CDEK `packages[0].items[0].name` и
`comment` верхнего уровня.
- `phoneExt` пробрасывается в CDEK `phones[0].additional`.
- Сохранять весь принятый payload в JSONB-колонке `payload` записи `orders`.
- ORM-модель `orders` рефакторится: вместо колонок `sender/recipient/
from_location/to_location/packages/services/comment/delivery_type` —
одна колонка `payload JSONB NOT NULL`, плюс `account_email VARCHAR`.
Колонки `order_uuid`, `payment_url`, `price`, `tariff_code`, `payment_status`,
`tbank_payment_id`, `cdek_order_uuid`, `created_at`, `updated_at` сохраняются.
## Acceptance criteria
- `POST /api/v1/delivery/order` принимает новый camelCase payload и возвращает
`InitPaymentResponse` без изменений (`{ "payment_url": str }`).
- Pydantic-модели валидируют: обязательность реквизитов юр-лица при
`isCompany=true`; `parcelType='doc'` ⇒ `dimensions=null`/отсутствует;
`parcelType='parcel'` ⇒ `dimensions` обязателен; `price > 0`.
- Service вызывает `payment_price_validation_adapter.get_payment_price(request)`
и `payment_adapter.create_payment_link(order_uuid, amount_kopecks)` с
`amount_kopecks = request.systemData.tariff.price`.
- CDEK order mapper из нового `InitPaymentRequest` формирует payload с полями:
`number`, `type=2`, `tariff_code`, `sender`, `recipient`,
`from_location.code/address/postal_code`, `to_location.code/address/postal_code`,
`packages[0]` с `weight` (граммы) и опциональными dimensions,
`packages[0].items` с описанием груза, `shipment_point.date`,
`delivery_point.date` (если есть), `comment`.
- При `isCompany=true` CDEK получает `contragent_type="LEGAL_ENTITY"`, `company`,
`inn`, `kpp` для соответствующей стороны.
- ORM `Order` хранит весь payload в `payload` JSONB; репозиторий сохраняет и
читает его без потерь; миграция переводит таблицу со старой структуры на
новую (drop старые колонки, добавить `payload`, `account_email`).
## Definition of Done
- [ ] Pydantic-модели `payment.py` переписаны под новый camelCase-контракт с
обязательными валидациями.
- [ ] CDEK order mapper и `_build_payment_price_payload` собирают payload из
новой структуры.
- [ ] `AggregatorService.init_payment` использует `systemData.tariff.price` и
`orderUuid` напрямую; persist использует обновлённый `OrderData`.
- [ ] ORM-модель `Order`, `OrderData` и репозиторий перешли на `payload` JSONB
и `account_email`.
- [ ] Alembic-миграция `20260513_032_rework_orders_payload.py` применяется и
откатывается.
- [ ] Тесты на schemas, mapper, payment validation, controller и service
обновлены и проходят.
- [ ] `http-client.http` обновлён под новый payload.
## Tests
- `tests/controllers/v1/test_init_payment.py` — fixture/assertion обновлены под
camelCase; добавлены кейсы валидации юр-лица и doc/parcel dimensions.
- `tests/adapters/delivery_providers/cdek/test_order_mapper.py` —
mapping `phoneExt → additional`, контрагент юр-лица, address composition,
doc без dimensions, shipment/delivery point dates.
- `tests/adapters/delivery_providers/cdek/test_payment_price_validation.py` —
validation payload собирается из `senderAddress.cityId`, `systemData.weight`,
`systemData.dimensions`.
- `tests/services/test_init_payment.py` — `init_payment` берёт сумму из
`systemData.tariff.price`, `orderUuid` пробрасывается.
- `tests/repositories/order/test_repository.py` (если есть) — обновлён под
новую модель `Order`.
## Commands
- `pytest tests/`
- `alembic upgrade head` (smoke на пустой базе)
- `python3 spec/gen_spec_index.py --check`
View File
@@ -10,7 +10,10 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
from app.schemas.payment import InitPaymentRequest from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from tests.payment_fixtures import make_init_payment_request
class StubAuthClient: class StubAuthClient:
@@ -51,179 +54,53 @@ class SequenceHTTPClient:
return result return result
def _make_order_request(**overrides: object) -> InitPaymentRequest: def _make_provider(http_client: SequenceHTTPClient, **kwargs: Any) -> CDEKProvider:
payload: dict[str, object] = { return CDEKProvider(
"order_uuid": "order-uuid-1",
"price": 125000,
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"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_register_order_posts_cdek_contract_payload_and_maps_response() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
)
http_client = SequenceHTTPClient([response])
provider = CDEKProvider(
CDEKClient( CDEKClient(
http_client=http_client, # type: ignore[arg-type] http_client=http_client, # type: ignore[arg-type]
auth_client=StubAuthClient(), # type: ignore[arg-type] auth_client=StubAuthClient(), # type: ignore[arg-type]
base_url="https://api.cdek.test/v2", base_url="https://api.cdek.test/v2",
timeout_seconds=7.5, timeout_seconds=7.5,
retry_attempts=0, retry_attempts=0,
**kwargs,
) )
) )
result = asyncio.run(provider.register_order(_make_order_request()))
assert result == "cdek-order-uuid" def test_provider_register_order_posts_payload_and_maps_response() -> None:
assert http_client.calls == [ response = httpx.Response(
200,
json={
"entity": {"uuid": "cdek-order-uuid"},
"related_entities": [
{ {
"method": "POST", "type": "waybill",
"url": "https://api.cdek.test/v2/orders", "uuid": "waybill-uuid-1",
"json": { "url": "https://cdek.test/waybill/1.pdf",
"number": "order-uuid-1",
"type": 2,
"tariff_code": 535,
"comment": "Test order",
"sender": {
"name": "Petr Petrov",
"email": "sender@example.com",
"phones": [{"number": "+79009876543"}],
},
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phones": [{"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": 1000,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
} }
], ],
}, },
"data": None,
"headers": {"Authorization": "Bearer test-token"},
"timeout": 7.5,
}
]
def test_provider_register_order_omits_services_when_none_and_converts_package_weights() -> None:
response = httpx.Response(
200,
json={"entity": {"uuid": "cdek-order-uuid"}},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"), request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
) )
http_client = SequenceHTTPClient([response]) http_client = SequenceHTTPClient([response])
provider = CDEKProvider( provider = _make_provider(http_client)
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,
)
)
request = _make_order_request(
services=None,
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
],
)
asyncio.run(provider.register_order(request)) result = asyncio.run(provider.register_order(make_init_payment_request()))
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
assert http_client.calls[0]["url"] == "https://api.cdek.test/v2/orders"
payload = http_client.calls[0]["json"] payload = http_client.calls[0]["json"]
assert "services" not in payload assert payload["number"] == "order-uuid-1"
assert payload["sender"]["phones"] == [{"number": "+79009876543"}] assert payload["type"] == 2
assert payload["recipient"]["phones"] == [{"number": "+79001234567"}] assert payload["tariff_code"] == 535
assert payload["packages"] == [ assert payload["print"] == "WAYBILL"
{ assert payload["from_location"]["code"] == 7017
"number": "1", assert payload["to_location"]["code"] == 16454
"weight": 1000, assert payload["packages"][0]["weight"] == 1000
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
},
{
"number": "2",
"weight": 2000,
"length": 25,
"width": 18,
"height": 12,
"comment": "Package 2",
},
]
def test_cdek_client_register_order_maps_4xx_to_request_error() -> None: def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
@@ -241,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None:
) )
with pytest.raises(CDEKRequestError, match="status 422"): with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 1 assert len(http_client.calls) == 1
@@ -272,41 +149,13 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N
retry_attempts=2, retry_attempts=2,
) )
result = asyncio.run(client.register_order(_make_order_request())) result = asyncio.run(client.register_order(make_init_payment_request()))
assert result == "existing-cdek-order-uuid" assert result == CDEKOrderRegistrationResult(
assert len(http_client.calls) == 1 order_uuid="existing-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
def test_cdek_client_register_order_4xx_with_unrelated_entity_uuid_raises_request_error() -> None:
unrelated_response = httpx.Response(
409,
json={
"entity": {"uuid": "unrelated-uuid"},
"requests": [
{
"errors": [
{
"code": "v2_recipient_phone_invalid",
"message": "Recipient phone is invalid",
}
]
}
],
},
request=httpx.Request("POST", "https://api.cdek.test/v2/orders"),
) )
http_client = SequenceHTTPClient([unrelated_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):
asyncio.run(client.register_order(_make_order_request()))
assert len(http_client.calls) == 1 assert len(http_client.calls) == 1
@@ -337,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non
) )
with pytest.raises(CDEKClientError, match="retriable status 503"): with pytest.raises(CDEKClientError, match="retriable status 503"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request()))
assert len(http_client.calls) == 2 assert len(http_client.calls) == 2
assert sleep_calls == [0.25] assert sleep_calls == [0.25]
@@ -358,4 +207,4 @@ def test_cdek_client_register_order_raises_client_error_for_invalid_success_payl
) )
with pytest.raises(CDEKClientError, match="response payload is invalid"): with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(client.register_order(_make_order_request())) asyncio.run(client.register_order(make_init_payment_request()))
@@ -1,53 +1,209 @@
from app.adapters.delivery_providers.cdek.order_mapper import ( from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
compose_address_line,
map_cdek_existing_order_response,
map_cdek_order_request, map_cdek_order_request,
map_cdek_order_response,
) )
from app.schemas.payment import InitPaymentRequest from app.schemas.payment import Address
from tests.payment_fixtures import make_init_payment_request
def _make_order_request(**overrides: object) -> InitPaymentRequest: def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None:
payload: dict[str, object] = { payload = map_cdek_order_request(make_init_payment_request())
"order_uuid": "order-uuid-1",
"price": 125000, assert payload["number"] == "order-uuid-1"
"type": 2, assert payload["type"] == 2
"tariff_code": 535, assert payload["tariff_code"] == 535
"comment": "Test order",
"sender": {
"name": "Petr Petrov", def test_order_payload_requests_waybill_print() -> None:
"email": "sender@example.com", payload = map_cdek_order_request(make_init_payment_request())
"phone": {"number": "+79009876543"},
}, assert payload["print"] == "WAYBILL"
"recipient": {
"name": "Ivan Ivanov",
"email": "ivan@example.com", def test_map_cdek_order_response_extracts_order_uuid_without_waybill() -> None:
"phone": {"number": "+79001234567"}, result = map_cdek_order_response({"entity": {"uuid": "cdek-order-uuid"}})
},
"from_location": { assert result == CDEKOrderRegistrationResult(
"address": "Lenina 1", order_uuid="cdek-order-uuid",
"city": "Moscow", waybill_uuid=None,
"country_code": "RU", waybill_url=None,
}, )
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk", def test_map_cdek_order_response_extracts_waybill_from_related_entities() -> None:
"country_code": "RU", result = map_cdek_order_response(
},
"services": [{"code": "INSURANCE", "parameter": "1000"}],
"packages": [
{ {
"number": "1", "entity": {"uuid": "cdek-order-uuid"},
"weight": 1, "related_entities": [
"length": 20, {"type": "delivery", "uuid": "ignored"},
"width": 15, {
"height": 10, "type": "waybill",
"comment": "Package 1", "uuid": "waybill-uuid-1",
"url": "https://cdek.test/waybill/1.pdf",
},
],
}
)
assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> None:
result = map_cdek_existing_order_response(
{
"entity": {"uuid": "existing-uuid"},
"requests": [
{"errors": [{"code": "v2_entity_already_exists", "message": "dup"}]}
],
"related_entities": [
{
"type": "waybill",
"uuid": "waybill-existing",
"url": "https://cdek.test/waybill/existing.pdf",
} }
], ],
} }
payload.update(overrides) )
return InitPaymentRequest(**payload)
assert result == CDEKOrderRegistrationResult(
order_uuid="existing-uuid",
waybill_uuid="waybill-existing",
waybill_url="https://cdek.test/waybill/existing.pdf",
)
def test_cdek_order_payload_uses_order_uuid_as_external_number() -> None: def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None:
payload = map_cdek_order_request(_make_order_request()) payload = map_cdek_order_request(make_init_payment_request())
assert payload["number"] == "order-uuid-1" assert payload["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [
{"number": "+79001234567", "additional": "101"}
]
def test_order_payload_maps_locations_with_address_and_postal_code() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["from_location"] == {
"code": 7017,
"address": "Дубай, Sheikh Zayed Road, 10, кв. 201",
"postal_code": "12345",
}
assert payload["to_location"] == {
"code": 16454,
"address": "Шарджа, Al Wahda, 5",
"postal_code": "54321",
}
def test_order_payload_for_parcel_includes_dimensions() -> None:
payload = map_cdek_order_request(make_init_payment_request())
package = payload["packages"][0]
assert package["weight"] == 1000
assert package["length"] == 20
assert package["width"] == 15
assert package["height"] == 10
def test_order_payload_for_doc_omits_dimensions() -> None:
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
payload = map_cdek_order_request(request)
package = payload["packages"][0]
assert package["weight"] == 500
assert "length" not in package
assert "width" not in package
assert "height" not in package
def test_order_payload_omits_shipment_and_delivery_point() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert "shipment_point" not in payload
assert "delivery_point" not in payload
def test_order_payload_includes_company_requisites_for_legal_entity() -> None:
request = make_init_payment_request(
senderContact={
"fullName": "Romashka LLC",
"email": "buh@romashka.test",
"phone": "+74950000000",
"phoneExt": None,
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
sender = map_cdek_order_request(request)["sender"]
assert sender["contragent_type"] == "LEGAL_ENTITY"
assert sender["company"] == "Romashka LLC"
assert sender["inn"] == "7707083893"
assert sender["kpp"] == "770701001"
def test_order_payload_propagates_description_to_comments_without_items() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["comment"] == "Headphones"
assert payload["packages"][0]["comment"] == "Headphones"
assert "items" not in payload["packages"][0]
def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request(
make_init_payment_request(content={"description": None})
)
assert payload["packages"][0]["comment"] == "order-uuid-1"
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None:
payload = map_cdek_order_request(make_init_payment_request())
assert payload["sender"]["company"] == "Petr Petrov"
assert payload["recipient"]["company"] == "Ivan Ivanov"
assert "contragent_type" not in payload["sender"]
assert "contragent_type" not in payload["recipient"]
def test_compose_address_line_handles_empty_apartment() -> None:
address = Address(
cityId=1,
city="Москва",
street="Lenina",
house="1",
apartment=None,
zip="12345",
comment=None,
)
assert compose_address_line(address) == "Москва, Lenina, 1"
@@ -11,7 +11,7 @@ from app.adapters.delivery_providers.cdek.client import (
CDEKProvider, CDEKProvider,
CDEKRequestError, CDEKRequestError,
) )
from app.schemas.payment import InitPaymentRequest from tests.payment_fixtures import make_init_payment_request
class StubAuthClient: class StubAuthClient:
@@ -52,62 +52,11 @@ class SequenceHTTPClient:
return result return result
def _make_request(**overrides: object) -> InitPaymentRequest: def test_provider_get_payment_price_posts_payload_from_camelcase_request() -> None:
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( response = httpx.Response(
200, 200,
json={ json={
"tariff_codes": [ "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_code": 535,
"tariff_name": "CDEK tariff", "tariff_name": "CDEK tariff",
@@ -131,11 +80,9 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
) )
) )
result = asyncio.run(provider.get_payment_price(_make_request())) result = asyncio.run(provider.get_payment_price(make_init_payment_request()))
assert result is not None assert result is not None
assert result.provider == "cdek"
assert result.service_name == "CDEK tariff"
assert result.price == Decimal("1250.00") assert result.price == Decimal("1250.00")
assert http_client.calls == [ assert http_client.calls == [
{ {
@@ -143,16 +90,8 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"url": "https://api.cdek.test/v2/calculator/tarifflist", "url": "https://api.cdek.test/v2/calculator/tarifflist",
"json": { "json": {
"type": 2, "type": 2,
"from_location": { "from_location": {"code": 7017},
"address": "Lenina 1", "to_location": {"code": 16454},
"city": "Moscow",
"country_code": "RU",
},
"to_location": {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
},
"packages": [ "packages": [
{ {
"weight": 1000, "weight": 1000,
@@ -161,7 +100,6 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
"height": 10, "height": 10,
} }
], ],
"services": [{"code": "INSURANCE", "parameter": "1000"}],
}, },
"data": None, "data": None,
"headers": {"Authorization": "Bearer test-token"}, "headers": {"Authorization": "Bearer test-token"},
@@ -170,7 +108,7 @@ def test_provider_get_payment_price_posts_tarifflist_payload_and_maps_requested_
] ]
def test_provider_get_payment_price_omits_services_when_none() -> None: def test_provider_get_payment_price_omits_dimensions_for_doc() -> None:
response = httpx.Response( response = httpx.Response(
200, 200,
json={"tariff_codes": []}, json={"tariff_codes": []},
@@ -185,11 +123,28 @@ def test_provider_get_payment_price_omits_services_when_none() -> None:
retry_attempts=0, retry_attempts=0,
) )
) )
request = make_init_payment_request(
content={"description": "Docs"},
systemData={
"tariff": {
"provider": "СДЭК",
"serviceName": "Документы",
"price": 50000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "doc",
"docPackaging": "envelope",
"weight": "0.5",
"dimensions": None,
},
)
result = asyncio.run(provider.get_payment_price(_make_request(services=None))) asyncio.run(provider.get_payment_price(request))
assert result is None package = http_client.calls[0]["json"]["packages"][0]
assert "services" not in http_client.calls[0]["json"] assert package == {"weight": 500}
def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None: def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
@@ -207,7 +162,7 @@ def test_client_get_raw_payment_price_maps_4xx_to_request_error() -> None:
) )
with pytest.raises(CDEKRequestError, match="status 422"): with pytest.raises(CDEKRequestError, match="status 422"):
asyncio.run(client.get_raw_payment_price(_make_request())) asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 1 assert len(http_client.calls) == 1
@@ -239,7 +194,7 @@ def test_client_get_raw_payment_price_retries_5xx_and_raises_client_error() -> N
) )
with pytest.raises(CDEKClientError, match="status 503"): with pytest.raises(CDEKClientError, match="status 503"):
asyncio.run(client.get_raw_payment_price(_make_request())) asyncio.run(client.get_raw_payment_price(make_init_payment_request()))
assert len(http_client.calls) == 2 assert len(http_client.calls) == 2
assert sleep_calls == [0.25] assert sleep_calls == [0.25]
@@ -262,4 +217,4 @@ def test_provider_get_payment_price_maps_invalid_success_payload_to_client_error
) )
with pytest.raises(CDEKClientError, match="response payload is invalid"): with pytest.raises(CDEKClientError, match="response payload is invalid"):
asyncio.run(provider.get_payment_price(_make_request())) asyncio.run(provider.get_payment_price(make_init_payment_request()))
+140 -326
View File
@@ -9,6 +9,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError, InitPaymentUnavailableError,
InvalidInitPaymentRequestError, InvalidInitPaymentRequestError,
) )
from tests.payment_fixtures import make_init_payment_payload
class StubAggregatorService: class StubAggregatorService:
@@ -31,45 +32,16 @@ def _install_service_override(app, service: StubAggregatorService) -> None:
app.dependency_overrides[get_aggregator_service] = override_service app.dependency_overrides[get_aggregator_service] = override_service
def _valid_payload() -> dict[str, object]: def _post(app, payload: dict[str, object]) -> httpx.Response:
return { async def run_request() -> httpx.Response:
"order_uuid": "order-uuid-1", transport = httpx.ASGITransport(app=app, raise_app_exceptions=False)
"price": 125000, async with httpx.AsyncClient(
"type": 2, transport=transport,
"tariff_code": 535, base_url="http://testserver",
"comment": "Test payment", ) as client:
"sender": { return await client.post("/api/v1/delivery/order", json=payload)
"name": "Petr Petrov",
"email": "sender@example.com", return asyncio.run(run_request())
"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",
}
],
}
def test_post_init_payment_returns_response_and_delegates_to_service() -> None: def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
@@ -77,44 +49,64 @@ def test_post_init_payment_returns_response_and_delegates_to_service() -> None:
service = StubAggregatorService(response=expected_response) service = StubAggregatorService(response=expected_response)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
payload = make_init_payment_payload()
async def run_request() -> httpx.Response: response = _post(app, payload)
transport = httpx.ASGITransport(app=app)
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 == 200 assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json") assert response.json() == expected_response.model_dump(mode="json")
assert service.calls == [InitPaymentRequest(**_valid_payload())] assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_rejects_invalid_payload() -> None: def test_post_init_payment_rejects_invalid_tariff_code() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = make_init_payment_payload()
invalid_payload["tariff_code"] = "not-a-number" invalid_payload["systemData"]["tariff"]["tariffCode"] = "not-a-number"
async def run_request() -> httpx.Response: response = _post(app, invalid_payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request()) assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_address_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
sender_address = invalid_payload["senderAddress"]
sender_address["city_id"] = sender_address.pop("cityId")
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_snake_case_nested_tariff_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
tariff = invalid_payload["systemData"]["tariff"]
tariff["tariff_code"] = tariff.pop("tariffCode")
response = _post(app, invalid_payload)
assert response.status_code == 422 assert response.status_code == 422
assert service.calls == [] assert service.calls == []
@@ -124,21 +116,10 @@ def test_post_init_payment_rejects_missing_price() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = make_init_payment_payload()
del invalid_payload["price"] del invalid_payload["systemData"]["tariff"]["price"]
async def run_request() -> httpx.Response: response = _post(app, invalid_payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422 assert response.status_code == 422
assert service.calls == [] assert service.calls == []
@@ -148,21 +129,10 @@ def test_post_init_payment_rejects_non_positive_price() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = make_init_payment_payload()
invalid_payload["price"] = 0 invalid_payload["systemData"]["tariff"]["price"] = 0
async def run_request() -> httpx.Response: response = _post(app, invalid_payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422 assert response.status_code == 422
assert service.calls == [] assert service.calls == []
@@ -172,155 +142,102 @@ def test_post_init_payment_rejects_non_integer_price() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = make_init_payment_payload()
invalid_payload["price"] = 125000.5 invalid_payload["systemData"]["tariff"]["price"] = 125000.5
async def run_request() -> httpx.Response: response = _post(app, invalid_payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422 assert response.status_code == 422
assert service.calls == [] assert service.calls == []
def test_post_init_payment_rejects_sender_company_field() -> None: def test_post_init_payment_requires_company_fields_when_is_company_true() -> None:
service = StubAggregatorService(response=None) service = StubAggregatorService(response=None)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = _valid_payload() invalid_payload = make_init_payment_payload()
invalid_payload["sender"] = { invalid_payload["senderContact"]["isCompany"] = True
**invalid_payload["sender"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
async def run_request() -> httpx.Response: response = _post(app, invalid_payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422 assert response.status_code == 422
assert service.calls == [] assert service.calls == []
def test_post_init_payment_rejects_sender_phones_field() -> None: def test_post_init_payment_accepts_company_with_full_requisites() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["sender"] = {
**invalid_payload["sender"], # type: ignore[arg-type]
"phones": [{"number": "+79009876543"}],
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_company_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"company": "Romashka LLC",
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_recipient_phones_field() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = _valid_payload()
invalid_payload["recipient"] = {
**invalid_payload["recipient"], # type: ignore[arg-type]
"phones": [{"number": "+79001234567"}],
}
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post(
"/api/v1/delivery/order",
json=invalid_payload,
)
response = asyncio.run(run_request())
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_request_without_services() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1") expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response) service = StubAggregatorService(response=expected_response)
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
payload = _valid_payload() payload = make_init_payment_payload()
del payload["services"] payload["senderContact"].update(
{
"isCompany": True,
"companyName": "Romashka LLC",
"inn": "7707083893",
"kpp": "770701001",
}
)
async def run_request() -> httpx.Response: response = _post(app, payload)
transport = httpx.ASGITransport(app=app)
async with httpx.AsyncClient(
transport=transport,
base_url="http://testserver",
) as client:
return await client.post("/api/v1/delivery/order", json=payload)
response = asyncio.run(run_request())
assert response.status_code == 200 assert response.status_code == 200
assert response.json() == expected_response.model_dump(mode="json") assert service.calls == [InitPaymentRequest.model_validate(payload)]
assert service.calls == [InitPaymentRequest(**payload)]
def test_post_init_payment_rejects_non_numeric_weight() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["weight"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_non_numeric_dimension() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"]["length"] = "string"
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_rejects_parcel_without_dimensions() -> None:
service = StubAggregatorService(response=None)
app = create_app()
_install_service_override(app, service)
invalid_payload = make_init_payment_payload()
invalid_payload["systemData"]["dimensions"] = None
response = _post(app, invalid_payload)
assert response.status_code == 422
assert service.calls == []
def test_post_init_payment_accepts_doc_without_dimensions() -> None:
expected_response = InitPaymentResponse(payment_url="https://pay.test/payment/1")
service = StubAggregatorService(response=expected_response)
app = create_app()
_install_service_override(app, service)
payload = make_init_payment_payload()
payload["systemData"]["parcelType"] = "doc"
payload["systemData"]["docPackaging"] = "envelope"
payload["systemData"]["dimensions"] = None
response = _post(app, payload)
assert response.status_code == 200
assert service.calls == [InitPaymentRequest.model_validate(payload)]
def test_post_init_payment_maps_invalid_request_to_400() -> None: def test_post_init_payment_maps_invalid_request_to_400() -> None:
@@ -331,18 +248,7 @@ def test_post_init_payment_maps_invalid_request_to_400() -> None:
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
async def run_request() -> httpx.Response: response = _post(app, make_init_payment_payload())
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.status_code == 400
assert response.json() == { assert response.json() == {
@@ -353,37 +259,6 @@ 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,
@@ -392,18 +267,7 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
async def run_request() -> httpx.Response: response = _post(app, make_init_payment_payload())
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.status_code == 503
assert response.json() == { assert response.json() == {
@@ -412,53 +276,3 @@ def test_post_init_payment_maps_service_exception_to_503() -> None:
"message": "Payment initialization is temporarily unavailable.", "message": "Payment initialization is temporarily unavailable.",
} }
} }
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()
_install_service_override(app, service)
async def run_request() -> httpx.Response:
transport = httpx.ASGITransport(app=app)
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 == 404
assert service.calls == []
+81
View File
@@ -0,0 +1,81 @@
"""Shared fixtures for the camelCase init-payment contract."""
from typing import Any
from app.schemas.payment import InitPaymentRequest
def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"""Return a valid camelCase JSON payload for /api/v1/delivery/order."""
payload: dict[str, Any] = {
"orderUuid": "order-uuid-1",
"senderAddress": {
"cityId": 1,
"city": "Дубай",
"street": "Sheikh Zayed Road",
"house": "10",
"apartment": "201",
"zip": "12345",
"comment": "Domofon 12",
},
"senderContact": {
"fullName": "Petr Petrov",
"email": "sender@example.com",
"phone": "+79009876543",
"phoneExt": None,
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"receiverAddress": {
"cityId": 2,
"city": "Шарджа",
"street": "Al Wahda",
"house": "5",
"apartment": None,
"zip": "54321",
"comment": None,
},
"receiverContact": {
"fullName": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": "+79001234567",
"phoneExt": "101",
"isCompany": False,
"companyName": None,
"inn": None,
"kpp": None,
},
"content": {
"description": "Headphones",
},
"pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com",
"systemData": {
"tariff": {
"provider": "СДЭК",
"serviceName": "Экспресс лайт",
"price": 125000,
"deliveryDaysMin": 1,
"deliveryDaysMax": 2,
"tariffCode": 535,
},
"parcelType": "parcel",
"docPackaging": None,
"weight": "1.0",
"dimensions": {
"length": "20",
"width": "15",
"height": "10",
},
},
}
payload.update(overrides)
return payload
def make_init_payment_request(**overrides: Any) -> InitPaymentRequest:
return InitPaymentRequest.model_validate(make_init_payment_payload(**overrides))
+40 -64
View File
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import create_async_engine
from app.repositories.order import OrderData, OrderRepository from app.repositories.order import OrderData, OrderRepository
from app.repositories.order.models import Base, Order from app.repositories.order.models import Base, Order
from tests.payment_fixtures import make_init_payment_payload
def _make_order_data(**overrides: object) -> OrderData: def _make_order_data(**overrides: object) -> OrderData:
@@ -17,40 +18,9 @@ def _make_order_data(**overrides: object) -> OrderData:
"order_uuid": "order-uuid-1", "order_uuid": "order-uuid-1",
"payment_url": "https://pay.test/payment/1", "payment_url": "https://pay.test/payment/1",
"price": 125000, "price": 125000,
"delivery_type": 2,
"tariff_code": 535, "tariff_code": 535,
"comment": "Test payment", "account_email": "client@example.com",
"sender": { "payload": make_init_payment_payload(),
"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) payload.update(overrides)
return OrderData(**payload) return OrderData(**payload)
@@ -92,46 +62,20 @@ def test_create_order_persists_all_required_fields() -> None:
assert persisted_order.order_uuid == "order-uuid-1" assert persisted_order.order_uuid == "order-uuid-1"
assert persisted_order.payment_url == "https://pay.test/payment/1" assert persisted_order.payment_url == "https://pay.test/payment/1"
assert persisted_order.price == 125000 assert persisted_order.price == 125000
assert persisted_order.delivery_type == 2
assert persisted_order.tariff_code == 535 assert persisted_order.tariff_code == 535
assert persisted_order.sender == order_data.sender assert persisted_order.account_email == "client@example.com"
assert persisted_order.recipient == order_data.recipient assert persisted_order.payload == order_data.payload
assert persisted_order.from_location == order_data.from_location
assert persisted_order.to_location == order_data.to_location
assert persisted_order.packages == order_data.packages
assert persisted_order.services == order_data.services
assert persisted_order.comment == "Test payment"
assert persisted_order.payment_status is None assert persisted_order.payment_status is None
assert persisted_order.tbank_payment_id is None assert persisted_order.tbank_payment_id is None
assert persisted_order.cdek_order_uuid is None assert persisted_order.cdek_order_uuid is None
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
assert persisted_order.created_at is not None assert persisted_order.created_at is not None
assert persisted_order.updated_at is not None assert persisted_order.updated_at is not None
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_create_order_persists_nullable_services_and_comment() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
order_data = _make_order_data(services=None, comment=None)
async with repository.session() as session:
await repository.create_order(session, order_data)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.services is None
assert persisted_order.comment is None
asyncio.run(_with_repository(run))
def test_create_order_rejects_duplicate_order_uuid() -> None: def test_create_order_rejects_duplicate_order_uuid() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
@@ -229,7 +173,7 @@ def test_mark_payment_status_returns_none_for_missing_order() -> None:
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None: def test_mark_cdek_order_registered_persists_cdek_order_and_waybill() -> None:
async def run( async def run(
repository: OrderRepository, repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession], session_factory: async_sessionmaker[AsyncSession],
@@ -242,6 +186,8 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
session, session,
"order-uuid-1", "order-uuid-1",
"cdek-order-uuid-1", "cdek-order-uuid-1",
"waybill-uuid-1",
"https://cdek.test/waybill/1.pdf",
) )
async with session_factory() as session: async with session_factory() as session:
@@ -252,6 +198,36 @@ def test_mark_cdek_order_registered_persists_cdek_order_uuid() -> None:
assert order is not None assert order is not None
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1" assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid == "waybill-uuid-1"
assert persisted_order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
asyncio.run(_with_repository(run))
def test_mark_cdek_order_registered_allows_missing_waybill_fields() -> None:
async def run(
repository: OrderRepository,
session_factory: async_sessionmaker[AsyncSession],
) -> None:
async with repository.session() as session:
await repository.create_order(session, _make_order_data())
async with repository.session() as session:
await repository.mark_cdek_order_registered(
session,
"order-uuid-1",
"cdek-order-uuid-1",
)
async with session_factory() as session:
result = await session.execute(
select(Order).where(Order.order_uuid == "order-uuid-1")
)
persisted_order = result.scalar_one()
assert persisted_order.cdek_order_uuid == "cdek-order-uuid-1"
assert persisted_order.cdek_waybill_uuid is None
assert persisted_order.cdek_waybill_url is None
asyncio.run(_with_repository(run)) asyncio.run(_with_repository(run))
+25 -135
View File
@@ -16,6 +16,7 @@ from app.services.aggregator import (
InitPaymentUnavailableError, InitPaymentUnavailableError,
InvalidInitPaymentRequestError, InvalidInitPaymentRequestError,
) )
from tests.payment_fixtures import make_init_payment_payload, make_init_payment_request
class StubPaymentAdapter: class StubPaymentAdapter:
@@ -94,49 +95,6 @@ class StubOrderRepository:
return object() return object()
def _make_init_payment_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 _make_cdek_price(**overrides: object) -> DeliveryPrice: def _make_cdek_price(**overrides: object) -> DeliveryPrice:
payload = { payload = {
"provider": "cdek", "provider": "cdek",
@@ -151,7 +109,7 @@ def _make_cdek_price(**overrides: object) -> DeliveryPrice:
def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None: def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url() -> None:
request = _make_init_payment_request() request = make_init_payment_request()
events: list[str] = [] events: list[str] = []
validation_adapter = StubPaymentPriceValidationAdapter( validation_adapter = StubPaymentPriceValidationAdapter(
response=_make_cdek_price(), response=_make_cdek_price(),
@@ -172,8 +130,9 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
assert events == ["cdek", "tbank"] assert events == ["cdek", "tbank"]
def test_init_payment_persists_order_after_successful_payment_link() -> None: def test_init_payment_persists_order_payload_after_successful_payment_link() -> None:
request = _make_init_payment_request() payload = make_init_payment_payload()
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1") adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository() order_repository = StubOrderRepository()
@@ -184,59 +143,21 @@ def test_init_payment_persists_order_after_successful_payment_link() -> None:
order_repository=order_repository, order_repository=order_repository,
) )
result = asyncio.run(service.init_payment(request)) asyncio.run(service.init_payment(request))
assert result == InitPaymentResponse(payment_url="https://pay.test/payment/1") assert len(order_repository.calls) == 1
assert validation_adapter.calls == [request] session, order_data = order_repository.calls[0]
assert adapter.calls == [("order-uuid-1", 125000)] assert session is order_repository.session_value
assert order_repository.calls == [ assert order_data.order_uuid == "order-uuid-1"
( assert order_data.payment_url == "https://pay.test/payment/1"
order_repository.session_value, assert order_data.price == 125000
OrderData( assert order_data.tariff_code == 535
order_uuid="order-uuid-1", assert order_data.account_email == "client@example.com"
payment_url="https://pay.test/payment/1", assert order_data.payload == request.model_dump(mode="json", by_alias=True)
price=125000,
delivery_type=2,
tariff_code=535,
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",
},
packages=[
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
],
services=[{"code": "INSURANCE", "parameter": "1000"}],
comment="Test payment",
),
)
]
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()) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository(error=RuntimeError("database down")) order_repository = StubOrderRepository(error=RuntimeError("database down"))
@@ -250,13 +171,12 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None
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
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"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService( service = AggregatorService(
@@ -268,30 +188,9 @@ def test_init_payment_maps_provider_request_errors_to_invalid_payment_error() ->
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)]
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")
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
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"))
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
service = AggregatorService( service = AggregatorService(
@@ -303,19 +202,18 @@ def test_init_payment_maps_client_failures_to_unavailable_error() -> None:
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)]
def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None: def test_init_payment_without_configured_adapter_raises_unavailable_error() -> None:
service = AggregatorService(providers=[]) service = AggregatorService(providers=[])
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: def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_calls() -> None:
request = _make_init_payment_request(price=124999) payload = make_init_payment_payload()
payload["systemData"]["tariff"]["price"] = 124999
request = InitPaymentRequest.model_validate(payload)
adapter = StubPaymentAdapter(response="https://pay.test/payment/1") adapter = StubPaymentAdapter(response="https://pay.test/payment/1")
validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price()) validation_adapter = StubPaymentPriceValidationAdapter(response=_make_cdek_price())
order_repository = StubOrderRepository() order_repository = StubOrderRepository()
@@ -329,13 +227,12 @@ def test_init_payment_rejects_cdek_price_mismatch_without_tbank_or_repository_ca
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 == [] assert adapter.calls == []
assert order_repository.calls == [] assert order_repository.calls == []
def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> None: def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> 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=None) validation_adapter = StubPaymentPriceValidationAdapter(response=None)
service = AggregatorService( service = AggregatorService(
@@ -347,12 +244,11 @@ def test_init_payment_rejects_when_cdek_does_not_return_requested_tariff() -> No
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 == [] assert adapter.calls == []
def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None: def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> 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( validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderRequestError("bad CDEK payload") error=ProviderRequestError("bad CDEK payload")
@@ -366,12 +262,9 @@ def test_init_payment_maps_cdek_request_error_to_invalid_payment_error() -> None
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 == []
def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None: def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> 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( validation_adapter = StubPaymentPriceValidationAdapter(
error=ProviderClientError("CDEK unavailable") error=ProviderClientError("CDEK unavailable")
@@ -384,6 +277,3 @@ def test_init_payment_maps_cdek_client_error_to_unavailable_error() -> None:
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 == []
+61 -54
View File
@@ -1,9 +1,12 @@
import asyncio import asyncio
from dataclasses import dataclass from dataclasses import dataclass, field
from typing import Any from typing import Any
import pytest import pytest
from app.adapters.delivery_providers.cdek.order_mapper import (
CDEKOrderRegistrationResult,
)
from app.adapters.tbank.base import TBankPaymentNotificationTokenError from app.adapters.tbank.base import TBankPaymentNotificationTokenError
from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification from app.schemas.payment import InitPaymentRequest, TBankPaymentNotification
from app.services.aggregator import ( from app.services.aggregator import (
@@ -11,6 +14,11 @@ from app.services.aggregator import (
InvalidTBankPaymentNotificationError, InvalidTBankPaymentNotificationError,
TBankPaymentNotificationProcessingError, TBankPaymentNotificationProcessingError,
) )
from tests.payment_fixtures import make_init_payment_payload
def _default_payload() -> dict[str, Any]:
return make_init_payment_payload()
@dataclass @dataclass
@@ -18,57 +26,14 @@ class StoredOrder:
order_uuid: str = "order-uuid-1" order_uuid: str = "order-uuid-1"
payment_url: str = "https://pay.test/payment/1" payment_url: str = "https://pay.test/payment/1"
price: int = 125000 price: int = 125000
delivery_type: int = 2
tariff_code: int = 535 tariff_code: int = 535
comment: str | None = "Test payment" account_email: str = "client@example.com"
sender: dict[str, Any] | None = None payload: dict[str, Any] = field(default_factory=_default_payload)
recipient: dict[str, Any] | None = None
from_location: dict[str, Any] | None = None
to_location: dict[str, Any] | None = None
packages: list[dict[str, Any]] | None = None
services: list[dict[str, Any]] | None = None
payment_status: str | None = None payment_status: str | None = None
tbank_payment_id: int | None = None tbank_payment_id: int | None = None
cdek_order_uuid: str | None = None cdek_order_uuid: str | None = None
cdek_waybill_uuid: str | None = None
def __post_init__(self) -> None: cdek_waybill_url: str | None = None
if self.sender is None:
self.sender = {
"name": "Petr Petrov",
"email": "sender@example.com",
"phone": {"number": "+79009876543"},
}
if self.recipient is None:
self.recipient = {
"name": "Ivan Ivanov",
"email": "ivan@example.com",
"phone": {"number": "+79001234567"},
}
if self.from_location is None:
self.from_location = {
"address": "Lenina 1",
"city": "Moscow",
"country_code": "RU",
}
if self.to_location is None:
self.to_location = {
"address": "Pushkina 10",
"city": "Novosibirsk",
"country_code": "RU",
}
if self.packages is None:
self.packages = [
{
"number": "1",
"weight": 1,
"length": 20,
"width": 15,
"height": 10,
"comment": "Package 1",
}
]
if self.services is None:
self.services = [{"code": "INSURANCE", "parameter": "1000"}]
class StubPaymentAdapter: class StubPaymentAdapter:
@@ -148,9 +113,20 @@ class StubOrderRepository:
session: object, session: object,
order_uuid: str, order_uuid: str,
cdek_order_uuid: str, cdek_order_uuid: str,
cdek_waybill_uuid: str | None = None,
cdek_waybill_url: str | None = None,
) -> StoredOrder | None: ) -> StoredOrder | None:
self.calls.append( self.calls.append(
("mark_cdek_order_registered", (session, order_uuid, cdek_order_uuid)) (
"mark_cdek_order_registered",
(
session,
order_uuid,
cdek_order_uuid,
cdek_waybill_uuid,
cdek_waybill_url,
),
)
) )
if self._mark_cdek_errors: if self._mark_cdek_errors:
error = self._mark_cdek_errors.pop(0) error = self._mark_cdek_errors.pop(0)
@@ -161,6 +137,8 @@ class StubOrderRepository:
if order is None: if order is None:
return None return None
order.cdek_order_uuid = cdek_order_uuid order.cdek_order_uuid = cdek_order_uuid
order.cdek_waybill_uuid = cdek_waybill_uuid
order.cdek_waybill_url = cdek_waybill_url
return order return order
@@ -168,14 +146,22 @@ class StubCDEKOrderAdapter:
def __init__( def __init__(
self, self,
*, *,
responses: list[str] | None = None, responses: list[CDEKOrderRegistrationResult] | None = None,
error: Exception | None = None, error: Exception | None = None,
) -> None: ) -> None:
self._responses = responses or ["cdek-order-uuid-1"] self._responses = responses or [
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid=None,
waybill_url=None,
)
]
self._error = error self._error = error
self.calls: list[InitPaymentRequest] = [] self.calls: list[InitPaymentRequest] = []
async def register_order(self, request: InitPaymentRequest) -> str: async def register_order(
self, request: InitPaymentRequest
) -> CDEKOrderRegistrationResult:
self.calls.append(request) self.calls.append(request)
if self._error is not None: if self._error is not None:
raise self._error raise self._error
@@ -201,7 +187,15 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
order = StoredOrder() order = StoredOrder()
payment_adapter = StubPaymentAdapter() payment_adapter = StubPaymentAdapter()
order_repository = StubOrderRepository(orders=[order]) order_repository = StubOrderRepository(orders=[order])
cdek_adapter = StubCDEKOrderAdapter(responses=["cdek-order-uuid-1"]) cdek_adapter = StubCDEKOrderAdapter(
responses=[
CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid-1",
waybill_uuid="waybill-uuid-1",
waybill_url="https://cdek.test/waybill/1.pdf",
)
]
)
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],
payment_adapter=payment_adapter, payment_adapter=payment_adapter,
@@ -217,6 +211,8 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None:
assert order.payment_status == "CONFIRMED" assert order.payment_status == "CONFIRMED"
assert order.tbank_payment_id == 8347568144 assert order.tbank_payment_id == 8347568144
assert order.cdek_order_uuid == "cdek-order-uuid-1" assert order.cdek_order_uuid == "cdek-order-uuid-1"
assert order.cdek_waybill_uuid == "waybill-uuid-1"
assert order.cdek_waybill_url == "https://cdek.test/waybill/1.pdf"
assert len(cdek_adapter.calls) == 1 assert len(cdek_adapter.calls) == 1
assert cdek_adapter.calls[0].order_uuid == "order-uuid-1" assert cdek_adapter.calls[0].order_uuid == "order-uuid-1"
@@ -317,7 +313,18 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id()
mark_cdek_errors=[RuntimeError("db down"), None], mark_cdek_errors=[RuntimeError("db down"), None],
) )
cdek_adapter = StubCDEKOrderAdapter( cdek_adapter = StubCDEKOrderAdapter(
responses=["same-cdek-order-uuid", "same-cdek-order-uuid"] responses=[
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
CDEKOrderRegistrationResult(
order_uuid="same-cdek-order-uuid",
waybill_uuid=None,
waybill_url=None,
),
]
) )
service = AggregatorService( service = AggregatorService(
providers=[], providers=[],