Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 58s
Deploy / deploy (push) Successful in 58s
This commit is contained in:
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user