Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 1m0s

This commit is contained in:
Раис Юсупалиев
2026-05-27 23:29:51 +03:00
parent 50124fb2c9
commit 23dd12e51c
27 changed files with 592 additions and 166 deletions
@@ -153,9 +153,9 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order(
self, request: InitPaymentRequest
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult:
payload = map_cdek_order_request(request)
payload = map_cdek_order_request(request, order_uuid)
for attempt in range(self._retry_attempts + 1):
try:
token = await self._auth_client.get_access_token()
@@ -490,9 +490,9 @@ class CDEKProvider(DeliveryProvider):
) from exc
async def register_order(
self, request: InitPaymentRequest
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult:
return await self._client.register_order(request)
return await self._client.register_order(request, order_uuid)
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
return await self._client.get_order(cdek_order_uuid)
@@ -42,9 +42,11 @@ class CDEKWaybillInfo:
url: str | None
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
def map_cdek_order_request(
request: InitPaymentRequest, order_uuid: str
) -> dict[str, Any]:
payload: dict[str, Any] = {
"number": request.order_uuid,
"number": order_uuid,
"type": 2,
"tariff_code": request.system_data.tariff.tariff_code,
"print": _CDEK_WAYBILL_PRINT_TYPE,
@@ -52,7 +54,7 @@ def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
"recipient": _map_party(request.receiver_contact),
"from_location": _map_location(request.sender_address),
"to_location": _map_location(request.receiver_address),
"packages": [_map_package(request)],
"packages": [_map_package(request, order_uuid)],
}
if request.content.description:
payload["comment"] = request.content.description
@@ -280,12 +282,12 @@ def _map_location(address: Address) -> dict[str, Any]:
}
def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
system_data: SystemData = request.system_data
weight_grams = kilograms_string_to_grams(request.system_data.weight)
description = request.content.description or request.order_uuid
description = request.content.description or order_uuid
package: dict[str, Any] = {
"number": request.order_uuid,
"number": order_uuid,
"weight": weight_grams,
"comment": description,
}
+11 -10
View File
@@ -56,8 +56,8 @@ class SMTPEmailSender:
to: str,
subject: str,
body: str,
attachment_bytes: bytes,
attachment_filename: str,
attachment_bytes: bytes | None = None,
attachment_filename: str | None = None,
) -> None:
message = self._build_message(
to=to,
@@ -95,20 +95,21 @@ class SMTPEmailSender:
to: str,
subject: str,
body: str,
attachment_bytes: bytes,
attachment_filename: str,
attachment_bytes: bytes | None = None,
attachment_filename: str | None = None,
) -> EmailMessage:
message = EmailMessage()
message["From"] = self._from_address
message["To"] = to
message["Subject"] = subject
message.set_content(body)
message.add_attachment(
attachment_bytes,
maintype="application",
subtype="pdf",
filename=attachment_filename,
)
if attachment_bytes is not None and attachment_filename is not None:
message.add_attachment(
attachment_bytes,
maintype="application",
subtype="pdf",
filename=attachment_filename,
)
return message
+1 -1
View File
@@ -137,7 +137,7 @@ class TBankAdapter:
"Amount": amount_kopecks,
"OrderId": order_uuid,
"NotificationURL": self._notification_url,
"SuccessURL": self._success_url,
"SuccessURL": f"{self._success_url}/{order_uuid}",
}
payload["Token"] = _build_tbank_token(payload, password=self._password)
return payload
+11
View File
@@ -10,6 +10,7 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider,
)
from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter
from app.config import Settings
from app.controllers.http_client import build_controller_http_client
@@ -70,6 +71,15 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
postgres_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine)
order_repository = OrderRepository(session_factory=postgres_session_factory)
email_sender = SMTPEmailSender(
smtp_host=settings.email.smtp_host,
smtp_port=settings.email.smtp_port,
username=settings.email.username,
password=settings.email.password,
from_address=settings.email.from_address,
use_tls=settings.email.use_tls,
timeout_seconds=settings.email.timeout_seconds,
)
service = AggregatorService(
providers=providers,
cache=cache,
@@ -77,6 +87,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
payment_price_validation_adapter=cdek_provider,
order_repository=order_repository,
order_registration_adapter=cdek_provider,
email_sender=email_sender,
address_suggestion_providers=(
dadata_provider,
yandex_geosuggest_provider,
+4
View File
@@ -48,6 +48,10 @@ class Order(Base):
DateTime(timezone=True),
nullable=True,
)
payment_email_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
)
waybill_email_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True),
nullable=True,
+16
View File
@@ -145,6 +145,22 @@ class OrderRepository:
await session.flush()
return order
async def record_payment_email_sent(
self,
session: AsyncSession,
*,
order_uuid: str,
sent_at: datetime,
) -> Order | None:
order = await self.get_order_by_order_uuid(session, order_uuid)
if order is None:
return None
if order.payment_email_sent_at is None:
order.payment_email_sent_at = sent_at
await session.flush()
return order
async def list_orders_pending_waybill_email(
self,
session: AsyncSession,
+3 -1
View File
@@ -48,6 +48,7 @@ class Contact(_CamelModel):
email: str | None = None
phone: str = Field(min_length=1)
phone_ext: str | None = None
has_extra_phone: str | None = None
is_company: bool
company_name: str | None = None
inn: str | None = None
@@ -114,7 +115,6 @@ class SystemData(_CamelModel):
class InitPaymentRequest(_CamelModel):
order_uuid: str = Field(min_length=1)
sender_address: Address
sender_contact: Contact
receiver_address: Address
@@ -124,6 +124,8 @@ class InitPaymentRequest(_CamelModel):
delivery_date: datetime | None = None
account_email: EmailStr
system_data: SystemData
agree_privacy: bool
agree_terms: bool
class InitPaymentResponse(BaseModel):
+97 -17
View File
@@ -3,10 +3,12 @@
import asyncio
import hashlib
import json
from collections.abc import Iterable, Mapping, Sequence
from collections.abc import Callable, Iterable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from decimal import Decimal
from typing import Protocol
from uuid import uuid4
import structlog
@@ -113,7 +115,7 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol):
async def register_order(
self, request: InitPaymentRequest
self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ...
@@ -144,6 +146,16 @@ class OrderRepositoryProtocol(Protocol):
) -> object | None: ...
class EmailSenderProtocol(Protocol):
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
) -> None: ...
class FilterAndSortPricesFn(Protocol):
def __call__(
self,
@@ -165,12 +177,14 @@ class AggregatorService:
) = None,
order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
) -> None:
self._providers = tuple(providers)
self._cache = cache
@@ -178,9 +192,11 @@ class AggregatorService:
self._payment_price_validation_adapter = payment_price_validation_adapter
self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn
self._order_uuid_factory = order_uuid_factory
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
provider.name: provider for provider in address_suggestion_providers
}
@@ -259,17 +275,19 @@ class AggregatorService:
if self._payment_adapter is None:
raise InitPaymentUnavailableError("Payment adapter is not configured.")
await self._validate_init_payment_price(request)
order_uuid = self._order_uuid_factory()
await self._validate_init_payment_price(request, order_uuid)
try:
payment_url = await self._payment_adapter.create_payment_link(
order_uuid=request.order_uuid,
order_uuid=order_uuid,
amount_kopecks=request.system_data.tariff.price,
)
except TBankPaymentRequestError as exc:
logger.exception(
"payment_init_rejected",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
provider_status_code=exc.status_code,
provider_error_code=exc.error_code,
provider_error_message=exc.provider_message,
@@ -287,12 +305,15 @@ class AggregatorService:
"Payment initialization is temporarily unavailable."
) from exc
await self._persist_order(request=request, payment_url=payment_url)
await self._persist_order(
request=request, order_uuid=order_uuid, payment_url=payment_url
)
return InitPaymentResponse(payment_url=payment_url)
async def _validate_init_payment_price(
self,
request: InitPaymentRequest,
order_uuid: str,
) -> None:
if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError(
@@ -308,7 +329,7 @@ class AggregatorService:
except ProviderRequestError as exc:
logger.warning(
"init_payment_price_validation_request_rejected",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
@@ -319,7 +340,7 @@ class AggregatorService:
except ProviderClientError as exc:
logger.warning(
"init_payment_price_validation_unavailable",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
error=str(exc),
@@ -330,7 +351,7 @@ class AggregatorService:
except Exception as exc:
logger.exception(
"init_payment_price_validation_unexpected_error",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
@@ -341,7 +362,7 @@ class AggregatorService:
if provider_price is None:
logger.warning(
"init_payment_price_validation_tariff_not_found",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
)
@@ -360,7 +381,7 @@ class AggregatorService:
):
logger.warning(
"init_payment_price_mismatch",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
tariff_code=tariff_code,
requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks,
@@ -405,13 +426,64 @@ class AggregatorService:
if existing_cdek_order_uuid:
return "OK"
registration_result = await self._register_cdek_order(order)
registration_result = await self._register_cdek_order(
order, order_uuid=notification.OrderId
)
await self._save_cdek_order_uuid(
order_uuid=notification.OrderId,
cdek_order_uuid=registration_result.order_uuid,
)
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
)
return "OK"
async def _send_payment_confirmation_email(
self,
order: object,
*,
order_uuid: str,
) -> None:
if self._email_sender is None or self._order_repository is None:
return
if getattr(order, "payment_email_sent_at", None) is not None:
return
account_email = getattr(order, "account_email", None)
if account_email is None:
return
try:
await self._email_sender.send_email(
to=account_email,
subject=f"Оплата по заказу {order_uuid} принята",
body=(
f"Здравствуйте!\n\n"
f"Оплата по заказу {order_uuid} успешно принята.\n"
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
f"Спасибо за заказ!"
),
)
async with self._order_repository.session() as session:
await self._order_repository.record_payment_email_sent(
session,
order_uuid=order_uuid,
sent_at=datetime.now(timezone.utc),
)
logger.info(
"payment_confirmation_email_sent",
order_uuid=order_uuid,
account_email=account_email,
)
except Exception:
logger.warning(
"payment_confirmation_email_failed",
order_uuid=order_uuid,
account_email=account_email,
exc_info=True,
)
async def _load_order_and_mark_payment_status(
self,
notification: TBankPaymentNotification,
@@ -464,7 +536,7 @@ class AggregatorService:
) from exc
async def _register_cdek_order(
self, order: object
self, order: object, *, order_uuid: str
) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError(
@@ -473,7 +545,9 @@ class AggregatorService:
try:
request = self._to_init_payment_request_from_order(order)
return await self._order_registration_adapter.register_order(request)
return await self._order_registration_adapter.register_order(
request, order_uuid
)
except Exception as exc:
logger.exception(
"cdek_order_registration_failed",
@@ -526,6 +600,7 @@ class AggregatorService:
self,
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> None:
if self._order_repository is None:
@@ -535,22 +610,27 @@ class AggregatorService:
async with self._order_repository.session() as session:
await self._order_repository.create_order(
session,
self._to_order_data(request=request, payment_url=payment_url),
self._to_order_data(
request=request,
order_uuid=order_uuid,
payment_url=payment_url,
),
)
except Exception:
logger.exception(
"order_persistence_failed",
order_uuid=request.order_uuid,
order_uuid=order_uuid,
)
@staticmethod
def _to_order_data(
*,
request: InitPaymentRequest,
order_uuid: str,
payment_url: str,
) -> OrderData:
return OrderData(
order_uuid=request.order_uuid,
order_uuid=order_uuid,
payment_url=payment_url,
price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code,
+4 -4
View File
@@ -117,10 +117,10 @@ class WaybillEmailSenderService:
try:
summary = await self.poll_once()
logger.info(
"waybill_email_tick",
processed=summary.processed,
succeeded=summary.succeeded,
failed=summary.failed,
"waybill_email_tick "
f"processed={summary.processed} "
f"succeeded={summary.succeeded} "
f"failed={summary.failed}"
)
except Exception:
logger.exception("waybill_email_tick_failed")
+4 -4
View File
@@ -116,10 +116,10 @@ class WaybillPollerService:
try:
summary = await self.poll_once()
logger.info(
"waybill_poll_tick",
processed=summary.processed,
succeeded=summary.succeeded,
failed=summary.failed,
"waybill_poll_tick "
f"processed={summary.processed} "
f"succeeded={summary.succeeded} "
f"failed={summary.failed}"
)
except Exception:
logger.exception("waybill_poll_tick_failed")