From 6caea9e11100b57d6700ee2cd04cd9c808c115d0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=A0=D0=B0=D0=B8=D1=81=20=D0=AE=D1=81=D1=83=D0=BF=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B5=D0=B2?= Date: Wed, 27 May 2026 23:29:51 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20CI,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5=D0=BD?= =?UTF-8?q?=D0=B0=20=D0=BE=D1=82=D0=BF=D1=80=D0=B0=D0=B2=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BF=D0=B8=D1=81=D1=8C=D0=BC=D0=B0=20=D0=BE=20=D0=BF=D0=BE?= =?UTF-8?q?=D0=BB=D1=83=D1=87=D0=B5=D0=BD=D0=B8=D0=B8=20=D0=BE=D0=BF=D0=BB?= =?UTF-8?q?=D0=B0=D1=82=D1=8B,=20=D0=B4=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB?= =?UTF-8?q?=D0=B5=D0=BD=20order=5Fid=20=D0=B2=20success=5Furl?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitea/workflows/deploy.yml | 79 +++++++++++ .gitignore | 1 + Dockerfile | 1 - .../20260527_036_add_payment_email_sent_at.py | 27 ++++ .../delivery_providers/cdek/client.py | 8 +- .../delivery_providers/cdek/order_mapper.py | 14 +- app/adapters/email/smtp_client.py | 21 +-- app/adapters/tbank/client.py | 2 +- app/controllers/v1/delivery.py | 21 +++ app/repositories/order/models.py | 4 + app/repositories/order/repository.py | 16 +++ app/schemas/payment.py | 4 +- app/services/aggregator.py | 114 +++++++++++++--- app/services/waybill_email_sender.py | 8 +- app/services/waybill_poller.py | 8 +- config.example.yaml | 82 ----------- config.template.yaml | 116 ++++++++++++++++ ...compose.yml => docker-compose.template.yml | 45 +++++-- http-client.http | 1 - .../cdek/test_order_client.py | 10 +- .../cdek/test_order_mapper.py | 22 +-- tests/adapters/email/test_smtp_client.py | 21 +++ tests/adapters/tbank/test_client.py | 8 +- tests/controllers/v1/test_init_payment.py | 2 +- tests/payment_fixtures.py | 3 +- tests/services/test_init_payment.py | 3 + tests/services/test_tbank_notifications.py | 127 +++++++++++++++++- 27 files changed, 602 insertions(+), 166 deletions(-) create mode 100644 .gitea/workflows/deploy.yml create mode 100644 alembic/versions/20260527_036_add_payment_email_sent_at.py delete mode 100644 config.example.yaml create mode 100644 config.template.yaml rename docker-compose.yml => docker-compose.template.yml (57%) diff --git a/.gitea/workflows/deploy.yml b/.gitea/workflows/deploy.yml new file mode 100644 index 0000000..3ccdfd9 --- /dev/null +++ b/.gitea/workflows/deploy.yml @@ -0,0 +1,79 @@ +name: Deploy + +on: + push: + branches: + - master + +jobs: + deploy: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Build and push image + run: | + docker login gitea.p4r4dls.ru \ + -u ${{ secrets.REGISTRY_USER }} \ + -p ${{ secrets.REGISTRY_TOKEN }} + docker buildx create --use --name multibuilder + docker buildx build \ + --platform linux/amd64 \ + --push \ + -t gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 \ + . + + - name: Setup SSH + run: | + mkdir -p ~/.ssh + echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/deploy_key + chmod 600 ~/.ssh/deploy_key + ssh-keyscan 194.58.121.203 >> ~/.ssh/known_hosts + + - name: Render and copy configs + env: + ALL_SECRETS: ${{ toJSON(secrets) }} + run: | + set -euo pipefail + + apt update && apt install -y gettext-base + + # Экспортируем все секреты как env-переменные + while IFS= read -r -d '' entry; do + key="${entry%%=*}" + value="${entry#*=}" + export "$key=$value" + done < <(echo "$ALL_SECRETS" | jq -j 'to_entries[] | "\(.key)=\(.value)\u0000"') + + render() { + local src=$1 dst=$2 + vars=$(grep -oP '\$\{\K[^}]+' "$src") + for var in $vars; do + if [[ -z "${!var:-}" ]]; then + echo "Error: $var не задан в Gitea Secrets" >&2 + exit 1 + fi + done + envsubst < "$src" > "$dst" + } + + render config.template.yaml config.rendered.yaml + render docker-compose.template.yml docker-compose.rendered.yml + + scp -i ~/.ssh/deploy_key config.rendered.yaml \ + deploy@194.58.121.203:/home/deploy/g2s-aggregator/config.yaml + scp -i ~/.ssh/deploy_key docker-compose.rendered.yml \ + deploy@194.58.121.203:/home/deploy/g2s-aggregator/docker-compose.yml + + - name: Deploy + run: | + ssh -i ~/.ssh/deploy_key deploy@194.58.121.203 " + cd /home/deploy/g2s-aggregator && + docker login gitea.p4r4dls.ru \ + -u ${{ secrets.REGISTRY_USER }} \ + -p ${{ secrets.REGISTRY_TOKEN }} && + docker compose pull && + docker compose up -d + " diff --git a/.gitignore b/.gitignore index 7de30f4..4e0fea4 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ .idea *.iml /config.yaml +/docker-compose.yml __pycache__ http-client.private.env.json scripts diff --git a/Dockerfile b/Dockerfile index 613dbbe..426ef78 100644 --- a/Dockerfile +++ b/Dockerfile @@ -11,7 +11,6 @@ RUN poetry config virtualenvs.create false \ && poetry install --no-interaction --no-root COPY app ./app -COPY config.yaml ./config.yaml COPY alembic.ini ./alembic.ini COPY alembic ./alembic diff --git a/alembic/versions/20260527_036_add_payment_email_sent_at.py b/alembic/versions/20260527_036_add_payment_email_sent_at.py new file mode 100644 index 0000000..df1ebd0 --- /dev/null +++ b/alembic/versions/20260527_036_add_payment_email_sent_at.py @@ -0,0 +1,27 @@ +"""Add payment_email_sent_at column to orders table.""" + +from collections.abc import Sequence + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260527_036" +down_revision: str | None = "20260524_035" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.add_column( + "orders", + sa.Column( + "payment_email_sent_at", + sa.DateTime(timezone=True), + nullable=True, + ), + ) + + +def downgrade() -> None: + op.drop_column("orders", "payment_email_sent_at") diff --git a/app/adapters/delivery_providers/cdek/client.py b/app/adapters/delivery_providers/cdek/client.py index a9705e9..9a8271b 100644 --- a/app/adapters/delivery_providers/cdek/client.py +++ b/app/adapters/delivery_providers/cdek/client.py @@ -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) diff --git a/app/adapters/delivery_providers/cdek/order_mapper.py b/app/adapters/delivery_providers/cdek/order_mapper.py index 376099b..17280c1 100644 --- a/app/adapters/delivery_providers/cdek/order_mapper.py +++ b/app/adapters/delivery_providers/cdek/order_mapper.py @@ -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, } diff --git a/app/adapters/email/smtp_client.py b/app/adapters/email/smtp_client.py index d4890a9..b2aa7e6 100644 --- a/app/adapters/email/smtp_client.py +++ b/app/adapters/email/smtp_client.py @@ -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 diff --git a/app/adapters/tbank/client.py b/app/adapters/tbank/client.py index c721a67..11ba98d 100644 --- a/app/adapters/tbank/client.py +++ b/app/adapters/tbank/client.py @@ -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 diff --git a/app/controllers/v1/delivery.py b/app/controllers/v1/delivery.py index efab3ce..b49df03 100644 --- a/app/controllers/v1/delivery.py +++ b/app/controllers/v1/delivery.py @@ -1,5 +1,6 @@ """Delivery API controller skeleton.""" +import structlog from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.responses import PlainTextResponse @@ -10,6 +11,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 @@ -35,6 +37,8 @@ from app.services.aggregator import ( UnsupportedAddressSuggestionCountryError, ) +logger = structlog.get_logger(__name__) + router = APIRouter(prefix="/delivery", tags=["delivery"]) @@ -70,6 +74,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 +90,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, @@ -138,6 +152,13 @@ async def suggest_addresses( address_request: AddressSuggestRequest, service: AggregatorService = Depends(get_aggregator_service), ) -> list[AddressSuggestion]: + logger.info( + "suggest_address_requested", + country_code=address_request.country_code, + city=address_request.city, + query=address_request.query, + limit=address_request.limit, + ) try: return await service.suggest_addresses(address_request) except UnsupportedAddressSuggestionCountryError as exc: diff --git a/app/repositories/order/models.py b/app/repositories/order/models.py index b3c1333..aa9cf5e 100644 --- a/app/repositories/order/models.py +++ b/app/repositories/order/models.py @@ -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, diff --git a/app/repositories/order/repository.py b/app/repositories/order/repository.py index d58fc2f..c4d467b 100644 --- a/app/repositories/order/repository.py +++ b/app/repositories/order/repository.py @@ -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, diff --git a/app/schemas/payment.py b/app/schemas/payment.py index 7d80df8..a8e8b22 100644 --- a/app/schemas/payment.py +++ b/app/schemas/payment.py @@ -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): diff --git a/app/services/aggregator.py b/app/services/aggregator.py index 2599a7e..225f939 100644 --- a/app/services/aggregator.py +++ b/app/services/aggregator.py @@ -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, diff --git a/app/services/waybill_email_sender.py b/app/services/waybill_email_sender.py index db9a1a4..8c53a65 100644 --- a/app/services/waybill_email_sender.py +++ b/app/services/waybill_email_sender.py @@ -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") diff --git a/app/services/waybill_poller.py b/app/services/waybill_poller.py index 7ce03f7..6102cc1 100644 --- a/app/services/waybill_poller.py +++ b/app/services/waybill_poller.py @@ -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") diff --git a/config.example.yaml b/config.example.yaml deleted file mode 100644 index f417a3a..0000000 --- a/config.example.yaml +++ /dev/null @@ -1,82 +0,0 @@ -controller: - api_prefix: "/api/v1" - request_id_header: "X-Request-ID" - -service: - provider_timeout_seconds: 10.0 - max_parallel_providers: 8 - -business_logic: - weight_round_scale: 2 - provider_price_multiplier: 1.0 - -repository: - redis_dsn: "redis://localhost:6379/0" - price_cache_ttl_seconds: 900 - -adapter: - cdek_base_url: "https://api.cdek.ru/v2" - cdek_client_id: "" - cdek_client_secret: "" - cdek_retry_attempts: 2 - cdek_retry_backoff_seconds: 0.2 - cdek_timeout_seconds: 10.0 - cdek_cache_ttl_seconds: 900 - -tbank_payment: - init_url: "https://securepay.tinkoff.ru/v2/Init" - notification_url: "https://merchant.example.com/api/v1/delivery/tbank/notifications" - success_url: "https://merchant.example.com/payment/success" - auth: - terminal_key: "change-me-terminal-key" - password: "change-me-password" - timeout_seconds: 10.0 - retry_attempts: 2 - retry_backoff_seconds: 0.2 - -postgres: - dsn: "postgresql+asyncpg://postgres:postgres@postgres:5432/g2s_aggregator" - -address_suggestions: - country_to_provider: - RU: "dadata" - BY: "dadata" - KZ: "dadata" - AM: "yandex_geosuggest" - AZ: "yandex_geosuggest" - KG: "yandex_geosuggest" - MD: "yandex_geosuggest" - TJ: "yandex_geosuggest" - TM: "yandex_geosuggest" - UZ: "yandex_geosuggest" - dadata: - url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" - api_key: "" - timeout_seconds: 10.0 - yandex_geosuggest: - url: "https://suggest-maps.yandex.ru/v1/suggest" - api_key: "" - timeout_seconds: 10.0 - -observability: - enabled: false - service_name: "g2s-aggregator" - otlp_endpoint: "http://localhost:4317" - otlp_insecure: true - -waybill_poller: - interval_seconds: 30 - batch_size: 50 - -email: - smtp_host: "smtp.example.com" - smtp_port: 587 - username: "" - password: "" - from_address: "no-reply@example.com" - use_tls: true - timeout_seconds: 10.0 - -waybill_email_sender: - interval_seconds: 30 - batch_size: 50 diff --git a/config.template.yaml b/config.template.yaml new file mode 100644 index 0000000..1c40ab8 --- /dev/null +++ b/config.template.yaml @@ -0,0 +1,116 @@ +controller: + api_prefix: "/api/v1" + request_id_header: "X-Request-ID" + +service: + provider_timeout_seconds: 10.0 + max_parallel_providers: 8 + +business_logic: + weight_round_scale: 2 + provider_price_multiplier: 1.0 + +repository: + redis_dsn: "redis://localhost:6379/0" + price_cache_ttl_seconds: 900 + +adapter: + cdek_base_url: "https://api.edu.cdek.ru/v2" + cdek_client_id: "${CDEK_CLIENT_ID}" + cdek_client_secret: "${CDEK_CLIENT_SECRET}" + cdek_retry_attempts: 2 + cdek_retry_backoff_seconds: 0.2 + cdek_timeout_seconds: 10.0 + cdek_cache_ttl_seconds: 900 + +tbank_payment: + init_url: "https://securepay.tinkoff.ru/v2/Init" + notification_url: "https://aggregator.get2send.com/api/v1/delivery/tbank/notifications" + success_url: "https://aggregator.get2send.com/checkout/success" + auth: + terminal_key: "${TBANK_TERMINAL_KEY}" + password: "${TBANK_PASSWORD}" + timeout_seconds: 10.0 + retry_attempts: 2 + retry_backoff_seconds: 0.2 + +postgres: + dsn: "postgresql+asyncpg://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/g2s_aggregator" + +address_suggestions: + country_to_provider: + RU: "dadata" + BY: "dadata" + KZ: "dadata" + AM: "yandex_geosuggest" + AZ: "yandex_geosuggest" + KG: "yandex_geosuggest" + MD: "yandex_geosuggest" + TJ: "yandex_geosuggest" + TM: "yandex_geosuggest" + UZ: "yandex_geosuggest" + AL: "tomtom" + AT: "tomtom" + BE: "tomtom" + BG: "tomtom" + CH: "tomtom" + CZ: "tomtom" + DE: "tomtom" + DK: "tomtom" + EE: "tomtom" + ES: "tomtom" + FI: "tomtom" + FR: "tomtom" + GB: "tomtom" + GR: "tomtom" + HR: "tomtom" + HU: "tomtom" + IE: "tomtom" + IT: "tomtom" + LT: "tomtom" + LV: "tomtom" + NL: "tomtom" + "NO": "tomtom" + PL: "tomtom" + PT: "tomtom" + RO: "tomtom" + RS: "tomtom" + SE: "tomtom" + SI: "tomtom" + SK: "tomtom" + UA: "tomtom" + dadata: + url: "https://suggestions.dadata.ru/suggestions/api/4_1/rs/suggest/address" + api_key: "${DADATA_API_KEY}" + timeout_seconds: 7.5 + yandex_geosuggest: + url: "https://suggest-maps.yandex.ru/v1/suggest" + api_key: "${YANDEX_GEOSUGGEST_API_KEY}" + timeout_seconds: 7.5 + tomtom: + url: "https://api.tomtom.com/search/2/search" + api_key: "${TOMTOM_API_KEY}" + timeout_seconds: 7.5 + +waybill_poller: + interval_seconds: 10 + batch_size: 50 + +email: + smtp_host: "smtp.yandex.ru" + smtp_port: 465 + username: "${SMTP_USERNAME}" + password: "${SMTP_PASSWORD}" + from_address: "${SMTP_FROM_ADDRESS}" + use_tls: true + timeout_seconds: 10.0 + +waybill_email_sender: + interval_seconds: 10 + batch_size: 50 + +observability: + enabled: true + service_name: "g2s-aggregator" + otlp_endpoint: "${OTLP_ENDPOINT}" + otlp_insecure: true diff --git a/docker-compose.yml b/docker-compose.template.yml similarity index 57% rename from docker-compose.yml rename to docker-compose.template.yml index 4c43f69..b4fe0b3 100644 --- a/docker-compose.yml +++ b/docker-compose.template.yml @@ -1,9 +1,9 @@ services: app: - image: yusupal1ev/g2s-aggregator:0.0.7 + image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 container_name: g2s-aggregator ports: - - "8000:8000" + - "8003:8000" depends_on: redis: condition: service_started @@ -13,30 +13,36 @@ services: condition: service_completed_successfully volumes: - ./config.yaml:/config.yaml + restart: unless-stopped + logging: + driver: "json-file" + options: + tag: "g2s-aggregator" redis: image: redis:7-alpine container_name: redis command: ["redis-server", "--save", "", "--appendonly", "no"] ports: - "6379:6379" + restart: unless-stopped postgres: image: postgres:16-alpine container_name: postgres environment: POSTGRES_DB: g2s_aggregator - POSTGRES_USER: g2s_user - POSTGRES_PASSWORD: 7ed0a5a0f24be266 + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} ports: - "5432:5432" volumes: - postgres:/var/lib/postgresql/data healthcheck: - test: ["CMD-SHELL", "pg_isready -U g2s_user -d g2s_aggregator"] + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d g2s_aggregator"] interval: 5s timeout: 3s retries: 10 migrations: - image: yusupal1ev/g2s-aggregator:0.0.7 + image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 container_name: g2s-aggregator-migrations depends_on: postgres: @@ -45,8 +51,12 @@ services: - ./config.yaml:/config.yaml command: ["poetry", "run", "alembic", "upgrade", "head"] restart: "no" + logging: + driver: "json-file" + options: + tag: "g2s-aggregator-migrations" waybill-poller: - image: yusupal1ev/g2s-aggregator:0.0.7 + image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 container_name: g2s-aggregator-waybill-poller depends_on: postgres: @@ -57,8 +67,12 @@ services: - ./config.yaml:/config.yaml command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"] restart: unless-stopped + logging: + driver: "json-file" + options: + tag: "g2s-aggregator-waybill-poller" waybill-email-sender: - image: yusupal1ev/g2s-aggregator:0.0.7 + image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9 container_name: g2s-aggregator-waybill-email-sender depends_on: postgres: @@ -70,5 +84,20 @@ services: command: ["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"] restart: unless-stopped + logging: + driver: "json-file" + options: + tag: "g2s-aggregator-waybill-email-sender" + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + container_name: otel-collector + user: "0" + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + - /var/lib/docker/containers:/var/lib/docker/containers:ro + - /var/run/docker.sock:/var/run/docker.sock:ro + restart: unless-stopped + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: postgres: diff --git a/http-client.http b/http-client.http index 59980ff..112619e 100644 --- a/http-client.http +++ b/http-client.http @@ -17,7 +17,6 @@ POST http://localhost:8000/api/v1/delivery/order Content-Type: application/json { - "orderUuid": "order-uuid-1", "senderAddress": { "cityId": 1, "city": "Дубай", diff --git a/tests/adapters/delivery_providers/cdek/test_order_client.py b/tests/adapters/delivery_providers/cdek/test_order_client.py index b963cff..eff111f 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_client.py +++ b/tests/adapters/delivery_providers/cdek/test_order_client.py @@ -85,7 +85,7 @@ def test_provider_register_order_posts_payload_and_maps_response() -> None: http_client = SequenceHTTPClient([response]) provider = _make_provider(http_client) - result = asyncio.run(provider.register_order(make_init_payment_request())) + result = asyncio.run(provider.register_order(make_init_payment_request(), "order-uuid-1")) assert result == CDEKOrderRegistrationResult( order_uuid="cdek-order-uuid", @@ -118,7 +118,7 @@ def test_cdek_client_register_order_maps_4xx_to_request_error() -> None: ) with pytest.raises(CDEKRequestError, match="status 422"): - asyncio.run(client.register_order(make_init_payment_request())) + asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1")) assert len(http_client.calls) == 1 @@ -149,7 +149,7 @@ def test_cdek_client_register_order_maps_duplicate_external_id_to_success() -> N retry_attempts=2, ) - result = asyncio.run(client.register_order(make_init_payment_request())) + result = asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1")) assert result == CDEKOrderRegistrationResult( order_uuid="existing-cdek-order-uuid", @@ -186,7 +186,7 @@ def test_cdek_client_register_order_retries_5xx_and_raises_client_error() -> Non ) with pytest.raises(CDEKClientError, match="retriable status 503"): - asyncio.run(client.register_order(make_init_payment_request())) + asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1")) assert len(http_client.calls) == 2 assert sleep_calls == [0.25] @@ -207,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"): - asyncio.run(client.register_order(make_init_payment_request())) + asyncio.run(client.register_order(make_init_payment_request(), "order-uuid-1")) diff --git a/tests/adapters/delivery_providers/cdek/test_order_mapper.py b/tests/adapters/delivery_providers/cdek/test_order_mapper.py index a9fc032..c6a5e46 100644 --- a/tests/adapters/delivery_providers/cdek/test_order_mapper.py +++ b/tests/adapters/delivery_providers/cdek/test_order_mapper.py @@ -16,7 +16,7 @@ from tests.payment_fixtures import make_init_payment_request def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["number"] == "order-uuid-1" assert payload["type"] == 2 @@ -24,7 +24,7 @@ def test_order_payload_uses_order_uuid_and_tariff_code_from_system_data() -> Non def test_order_payload_requests_waybill_print() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["print"] == "WAYBILL" @@ -86,7 +86,7 @@ def test_map_cdek_existing_order_response_returns_waybill_for_duplicate() -> Non def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["sender"]["phones"] == [{"number": "+79009876543"}] assert payload["recipient"]["phones"] == [ @@ -95,7 +95,7 @@ def test_order_payload_maps_phones_and_phone_ext_to_additional() -> None: def test_order_payload_maps_locations_with_address_and_postal_code() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["from_location"] == { "code": 7017, @@ -110,7 +110,7 @@ def test_order_payload_maps_locations_with_address_and_postal_code() -> None: def test_order_payload_for_parcel_includes_dimensions() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") package = payload["packages"][0] assert package["weight"] == 1000 @@ -138,7 +138,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None: }, ) - payload = map_cdek_order_request(request) + payload = map_cdek_order_request(request, "order-uuid-1") package = payload["packages"][0] assert package["weight"] == 500 @@ -148,7 +148,7 @@ def test_order_payload_for_doc_omits_dimensions() -> None: def test_order_payload_omits_shipment_and_delivery_point() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert "shipment_point" not in payload assert "delivery_point" not in payload @@ -168,7 +168,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None: } ) - sender = map_cdek_order_request(request)["sender"] + sender = map_cdek_order_request(request, "order-uuid-1")["sender"] assert sender["contragent_type"] == "LEGAL_ENTITY" assert sender["company"] == "Romashka LLC" @@ -177,7 +177,7 @@ def test_order_payload_includes_company_requisites_for_legal_entity() -> None: def test_order_payload_propagates_description_to_comments_without_items() -> None: - payload = map_cdek_order_request(make_init_payment_request()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["comment"] == "Headphones" assert payload["packages"][0]["comment"] == "Headphones" @@ -186,14 +186,14 @@ def test_order_payload_propagates_description_to_comments_without_items() -> Non def test_order_payload_falls_back_package_comment_to_order_uuid() -> None: payload = map_cdek_order_request( - make_init_payment_request(content={"description": None}) + make_init_payment_request(content={"description": None}), "order-uuid-1" ) 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()) + payload = map_cdek_order_request(make_init_payment_request(), "order-uuid-1") assert payload["sender"]["company"] == "Petr Petrov" assert payload["recipient"]["company"] == "Ivan Ivanov" diff --git a/tests/adapters/email/test_smtp_client.py b/tests/adapters/email/test_smtp_client.py index 1c2d468..f5569f6 100644 --- a/tests/adapters/email/test_smtp_client.py +++ b/tests/adapters/email/test_smtp_client.py @@ -164,3 +164,24 @@ def test_send_email_passes_none_when_credentials_blank() -> None: call = send.calls[0] assert call["username"] is None assert call["password"] is None + + +def test_send_email_without_attachment_builds_plain_text_message() -> None: + send = StubSend() + sender = _make_sender(send) + + asyncio.run( + sender.send_email( + to="client@example.com", + subject="Оплата принята", + body="Ваша оплата принята.", + ) + ) + + assert len(send.calls) == 1 + message: EmailMessage = send.calls[0]["message"] + assert message["From"] == "no-reply@test" + assert message["To"] == "client@example.com" + assert message["Subject"] == "Оплата принята" + assert "Ваша оплата принята." in message.get_content() + assert list(message.iter_attachments()) == [] diff --git a/tests/adapters/tbank/test_client.py b/tests/adapters/tbank/test_client.py index 60e5ec3..b0bf104 100644 --- a/tests/adapters/tbank/test_client.py +++ b/tests/adapters/tbank/test_client.py @@ -95,7 +95,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None "https://example.test/api/v1/delivery/tbank/notifications" "order-uuid-1" "test-password" - "https://example.test/payment/success" + "https://example.test/payment/success/order-uuid-1" "TBankTest" ).encode("utf-8") ).hexdigest() @@ -109,7 +109,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None "Amount": 125000, "OrderId": "order-uuid-1", "NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications", - "SuccessURL": "https://example.test/payment/success", + "SuccessURL": "https://example.test/payment/success/order-uuid-1", "Token": expected_token, }, "data": None, @@ -159,7 +159,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None: "https://merchant.test/api/v1/delivery/tbank/notifications" "order-uuid-2" "config-password" - "https://merchant.test/payment/success" + "https://merchant.test/payment/success/order-uuid-2" "ConfigTerminal" ).encode("utf-8") ).hexdigest() @@ -169,7 +169,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None: "Amount": 9900, "OrderId": "order-uuid-2", "NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications", - "SuccessURL": "https://merchant.test/payment/success", + "SuccessURL": "https://merchant.test/payment/success/order-uuid-2", "Token": expected_token, } assert http_client.calls[0]["timeout"] == 6.25 diff --git a/tests/controllers/v1/test_init_payment.py b/tests/controllers/v1/test_init_payment.py index 097018e..4952139 100644 --- a/tests/controllers/v1/test_init_payment.py +++ b/tests/controllers/v1/test_init_payment.py @@ -76,7 +76,7 @@ def test_post_init_payment_rejects_snake_case_top_level_field() -> None: app = create_app() _install_service_override(app, service) invalid_payload = make_init_payment_payload() - invalid_payload["order_uuid"] = invalid_payload.pop("orderUuid") + invalid_payload["account_email"] = invalid_payload.pop("accountEmail") response = _post(app, invalid_payload) diff --git a/tests/payment_fixtures.py b/tests/payment_fixtures.py index 82c979d..bbf227d 100644 --- a/tests/payment_fixtures.py +++ b/tests/payment_fixtures.py @@ -9,7 +9,6 @@ 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": "Дубай", @@ -54,6 +53,8 @@ def make_init_payment_payload(**overrides: Any) -> dict[str, Any]: "pickupDate": "2026-05-15T10:00:00.000Z", "deliveryDate": "2026-05-18T18:00:00.000Z", "accountEmail": "client@example.com", + "agreePrivacy": True, + "agreeTerms": True, "systemData": { "tariff": { "provider": "СДЭК", diff --git a/tests/services/test_init_payment.py b/tests/services/test_init_payment.py index 289d0c5..25b715e 100644 --- a/tests/services/test_init_payment.py +++ b/tests/services/test_init_payment.py @@ -120,6 +120,7 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url( providers=[], payment_adapter=adapter, payment_price_validation_adapter=validation_adapter, + order_uuid_factory=lambda: "order-uuid-1", ) result = asyncio.run(service.init_payment(request)) @@ -141,6 +142,7 @@ def test_init_payment_persists_order_payload_after_successful_payment_link() -> payment_adapter=adapter, payment_price_validation_adapter=validation_adapter, order_repository=order_repository, + order_uuid_factory=lambda: "order-uuid-1", ) asyncio.run(service.init_payment(request)) @@ -166,6 +168,7 @@ def test_init_payment_returns_payment_url_when_order_persistence_fails() -> None payment_adapter=adapter, payment_price_validation_adapter=validation_adapter, order_repository=order_repository, + order_uuid_factory=lambda: "order-uuid-1", ) result = asyncio.run(service.init_payment(request)) diff --git a/tests/services/test_tbank_notifications.py b/tests/services/test_tbank_notifications.py index ab62c09..f803a24 100644 --- a/tests/services/test_tbank_notifications.py +++ b/tests/services/test_tbank_notifications.py @@ -34,6 +34,7 @@ class StoredOrder: cdek_order_uuid: str | None = None cdek_waybill_uuid: str | None = None cdek_waybill_url: str | None = None + payment_email_sent_at: object | None = None class StubPaymentAdapter: @@ -128,6 +129,34 @@ class StubOrderRepository: order.cdek_order_uuid = cdek_order_uuid return order + async def record_payment_email_sent( + self, + session: object, + *, + order_uuid: str, + sent_at: object, + ) -> StoredOrder | None: + self.calls.append( + ("record_payment_email_sent", (session, order_uuid, sent_at)) + ) + order = self._orders.get(order_uuid) + if order is not None and order.payment_email_sent_at is None: + order.payment_email_sent_at = sent_at + return order + + +class StubEmailSender: + def __init__(self, *, error: Exception | None = None) -> None: + self._error = error + self.calls: list[dict[str, str]] = [] + + async def send_email( + self, *, to: str, subject: str, body: str + ) -> None: + self.calls.append({"to": to, "subject": subject, "body": body}) + if self._error is not None: + raise self._error + class StubCDEKOrderAdapter: def __init__( @@ -144,12 +173,12 @@ class StubCDEKOrderAdapter: ) ] self._error = error - self.calls: list[InitPaymentRequest] = [] + self.calls: list[tuple[InitPaymentRequest, str]] = [] async def register_order( - self, request: InitPaymentRequest + self, request: InitPaymentRequest, order_uuid: str ) -> CDEKOrderRegistrationResult: - self.calls.append(request) + self.calls.append((request, order_uuid)) if self._error is not None: raise self._error return self._responses.pop(0) @@ -201,7 +230,7 @@ def test_confirmed_notification_registers_cdek_order_and_saves_uuid() -> None: assert order.cdek_waybill_uuid is None assert order.cdek_waybill_url is None assert len(cdek_adapter.calls) == 1 - assert cdek_adapter.calls[0].order_uuid == "order-uuid-1" + assert cdek_adapter.calls[0][1] == "order-uuid-1" def test_duplicate_confirmed_notification_does_not_call_cdek() -> None: @@ -330,7 +359,95 @@ def test_repeated_confirmed_after_cdek_uuid_save_failure_uses_same_external_id() assert result == "OK" assert order.cdek_order_uuid == "same-cdek-order-uuid" - assert [request.order_uuid for request in cdek_adapter.calls] == [ + assert [order_uuid for _, order_uuid in cdek_adapter.calls] == [ "order-uuid-1", "order-uuid-1", ] + + +def test_confirmed_notification_sends_payment_confirmation_email() -> None: + order = StoredOrder() + email_sender = StubEmailSender() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=StubCDEKOrderAdapter(), + email_sender=email_sender, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification(_make_notification()) + ) + + assert result == "OK" + assert len(email_sender.calls) == 1 + assert email_sender.calls[0]["to"] == "client@example.com" + assert "order-uuid-1" in email_sender.calls[0]["subject"] + assert order.payment_email_sent_at is not None + + +def test_non_confirmed_notification_does_not_send_email() -> None: + order = StoredOrder() + email_sender = StubEmailSender() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=StubCDEKOrderAdapter(), + email_sender=email_sender, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification( + _make_notification(Status="AUTHORIZED") + ) + ) + + assert result == "OK" + assert email_sender.calls == [] + assert order.payment_email_sent_at is None + + +def test_duplicate_notification_does_not_resend_payment_email() -> None: + from datetime import datetime, timezone + + order = StoredOrder( + cdek_order_uuid="existing-cdek-order-uuid", + payment_email_sent_at=datetime(2026, 1, 1, tzinfo=timezone.utc), + ) + email_sender = StubEmailSender() + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=StubCDEKOrderAdapter(), + email_sender=email_sender, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification(_make_notification()) + ) + + assert result == "OK" + assert email_sender.calls == [] + + +def test_email_failure_does_not_break_notification_handling() -> None: + order = StoredOrder() + email_sender = StubEmailSender(error=RuntimeError("smtp down")) + service = AggregatorService( + providers=[], + payment_adapter=StubPaymentAdapter(), + order_repository=StubOrderRepository(orders=[order]), + order_registration_adapter=StubCDEKOrderAdapter(), + email_sender=email_sender, + ) + + result = asyncio.run( + service.handle_tbank_payment_notification(_make_notification()) + ) + + assert result == "OK" + assert len(email_sender.calls) == 1 + assert order.cdek_order_uuid == "cdek-order-uuid-1"