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

This commit is contained in:
Раис Юсупалиев
2026-05-27 23:29:51 +03:00
parent 50124fb2c9
commit f1b91d09d8
25 changed files with 584 additions and 158 deletions
+79
View File
@@ -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
"
+1
View File
@@ -1,6 +1,7 @@
.idea .idea
*.iml *.iml
/config.yaml /config.yaml
/docker-compose.yml
__pycache__ __pycache__
http-client.private.env.json http-client.private.env.json
scripts scripts
-1
View File
@@ -11,7 +11,6 @@ RUN poetry config virtualenvs.create false \
&& poetry install --no-interaction --no-root && poetry install --no-interaction --no-root
COPY app ./app COPY app ./app
COPY config.yaml ./config.yaml
COPY alembic.ini ./alembic.ini COPY alembic.ini ./alembic.ini
COPY alembic ./alembic COPY alembic ./alembic
@@ -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")
@@ -153,9 +153,9 @@ class CDEKClient:
raise CDEKClientError("CDEK tariff request failed unexpectedly.") raise CDEKClientError("CDEK tariff request failed unexpectedly.")
async def register_order( async def register_order(
self, request: InitPaymentRequest self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ) -> CDEKOrderRegistrationResult:
payload = map_cdek_order_request(request) payload = map_cdek_order_request(request, order_uuid)
for attempt in range(self._retry_attempts + 1): for attempt in range(self._retry_attempts + 1):
try: try:
token = await self._auth_client.get_access_token() token = await self._auth_client.get_access_token()
@@ -490,9 +490,9 @@ class CDEKProvider(DeliveryProvider):
) from exc ) from exc
async def register_order( async def register_order(
self, request: InitPaymentRequest self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ) -> 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: async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
return await self._client.get_order(cdek_order_uuid) return await self._client.get_order(cdek_order_uuid)
@@ -42,9 +42,11 @@ class CDEKWaybillInfo:
url: str | None 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] = { payload: dict[str, Any] = {
"number": request.order_uuid, "number": order_uuid,
"type": 2, "type": 2,
"tariff_code": request.system_data.tariff.tariff_code, "tariff_code": request.system_data.tariff.tariff_code,
"print": _CDEK_WAYBILL_PRINT_TYPE, "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), "recipient": _map_party(request.receiver_contact),
"from_location": _map_location(request.sender_address), "from_location": _map_location(request.sender_address),
"to_location": _map_location(request.receiver_address), "to_location": _map_location(request.receiver_address),
"packages": [_map_package(request)], "packages": [_map_package(request, order_uuid)],
} }
if request.content.description: if request.content.description:
payload["comment"] = 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 system_data: SystemData = request.system_data
weight_grams = kilograms_string_to_grams(request.system_data.weight) 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] = { package: dict[str, Any] = {
"number": request.order_uuid, "number": order_uuid,
"weight": weight_grams, "weight": weight_grams,
"comment": description, "comment": description,
} }
+5 -4
View File
@@ -56,8 +56,8 @@ class SMTPEmailSender:
to: str, to: str,
subject: str, subject: str,
body: str, body: str,
attachment_bytes: bytes, attachment_bytes: bytes | None = None,
attachment_filename: str, attachment_filename: str | None = None,
) -> None: ) -> None:
message = self._build_message( message = self._build_message(
to=to, to=to,
@@ -95,14 +95,15 @@ class SMTPEmailSender:
to: str, to: str,
subject: str, subject: str,
body: str, body: str,
attachment_bytes: bytes, attachment_bytes: bytes | None = None,
attachment_filename: str, attachment_filename: str | None = None,
) -> EmailMessage: ) -> EmailMessage:
message = EmailMessage() message = EmailMessage()
message["From"] = self._from_address message["From"] = self._from_address
message["To"] = to message["To"] = to
message["Subject"] = subject message["Subject"] = subject
message.set_content(body) message.set_content(body)
if attachment_bytes is not None and attachment_filename is not None:
message.add_attachment( message.add_attachment(
attachment_bytes, attachment_bytes,
maintype="application", maintype="application",
+1 -1
View File
@@ -137,7 +137,7 @@ class TBankAdapter:
"Amount": amount_kopecks, "Amount": amount_kopecks,
"OrderId": order_uuid, "OrderId": order_uuid,
"NotificationURL": self._notification_url, "NotificationURL": self._notification_url,
"SuccessURL": self._success_url, "SuccessURL": f"{self._success_url}/{order_uuid}",
} }
payload["Token"] = _build_tbank_token(payload, password=self._password) payload["Token"] = _build_tbank_token(payload, password=self._password)
return payload return payload
+11
View File
@@ -10,6 +10,7 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
YandexGeosuggestAddressSuggestionProvider, YandexGeosuggestAddressSuggestionProvider,
) )
from app.adapters.delivery_providers.cdek import CDEKProvider from app.adapters.delivery_providers.cdek import CDEKProvider
from app.adapters.email import SMTPEmailSender
from app.adapters.tbank import TBankAdapter from app.adapters.tbank import TBankAdapter
from app.config import Settings from app.config import Settings
from app.controllers.http_client import build_controller_http_client 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_engine = create_postgres_engine(settings.postgres)
postgres_session_factory = create_postgres_session_factory(postgres_engine) postgres_session_factory = create_postgres_session_factory(postgres_engine)
order_repository = OrderRepository(session_factory=postgres_session_factory) 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( service = AggregatorService(
providers=providers, providers=providers,
cache=cache, cache=cache,
@@ -77,6 +87,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
payment_price_validation_adapter=cdek_provider, payment_price_validation_adapter=cdek_provider,
order_repository=order_repository, order_repository=order_repository,
order_registration_adapter=cdek_provider, order_registration_adapter=cdek_provider,
email_sender=email_sender,
address_suggestion_providers=( address_suggestion_providers=(
dadata_provider, dadata_provider,
yandex_geosuggest_provider, yandex_geosuggest_provider,
+4
View File
@@ -48,6 +48,10 @@ class Order(Base):
DateTime(timezone=True), DateTime(timezone=True),
nullable=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( waybill_email_sent_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), DateTime(timezone=True),
nullable=True, nullable=True,
+16
View File
@@ -145,6 +145,22 @@ class OrderRepository:
await session.flush() await session.flush()
return order 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( async def list_orders_pending_waybill_email(
self, self,
session: AsyncSession, session: AsyncSession,
+3 -1
View File
@@ -48,6 +48,7 @@ class Contact(_CamelModel):
email: str | None = None email: str | None = None
phone: str = Field(min_length=1) phone: str = Field(min_length=1)
phone_ext: str | None = None phone_ext: str | None = None
has_extra_phone: str | None = None
is_company: bool is_company: bool
company_name: str | None = None company_name: str | None = None
inn: str | None = None inn: str | None = None
@@ -114,7 +115,6 @@ class SystemData(_CamelModel):
class InitPaymentRequest(_CamelModel): class InitPaymentRequest(_CamelModel):
order_uuid: str = Field(min_length=1)
sender_address: Address sender_address: Address
sender_contact: Contact sender_contact: Contact
receiver_address: Address receiver_address: Address
@@ -124,6 +124,8 @@ class InitPaymentRequest(_CamelModel):
delivery_date: datetime | None = None delivery_date: datetime | None = None
account_email: EmailStr account_email: EmailStr
system_data: SystemData system_data: SystemData
agree_privacy: bool
agree_terms: bool
class InitPaymentResponse(BaseModel): class InitPaymentResponse(BaseModel):
+97 -17
View File
@@ -3,10 +3,12 @@
import asyncio import asyncio
import hashlib import hashlib
import json import json
from collections.abc import Iterable, Mapping, Sequence from collections.abc import Callable, Iterable, Mapping, Sequence
from contextlib import AbstractAsyncContextManager from contextlib import AbstractAsyncContextManager
from datetime import datetime, timezone
from decimal import Decimal from decimal import Decimal
from typing import Protocol from typing import Protocol
from uuid import uuid4
import structlog import structlog
@@ -113,7 +115,7 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
class OrderRegistrationAdapterProtocol(Protocol): class OrderRegistrationAdapterProtocol(Protocol):
async def register_order( async def register_order(
self, request: InitPaymentRequest self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ... ) -> CDEKOrderRegistrationResult: ...
@@ -144,6 +146,16 @@ class OrderRepositoryProtocol(Protocol):
) -> object | None: ... ) -> object | None: ...
class EmailSenderProtocol(Protocol):
async def send_email(
self,
*,
to: str,
subject: str,
body: str,
) -> None: ...
class FilterAndSortPricesFn(Protocol): class FilterAndSortPricesFn(Protocol):
def __call__( def __call__(
self, self,
@@ -165,12 +177,14 @@ class AggregatorService:
) = None, ) = None,
order_repository: OrderRepositoryProtocol | None = None, order_repository: OrderRepositoryProtocol | None = None,
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None, order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
email_sender: EmailSenderProtocol | None = None,
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (), address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
address_suggestion_country_to_provider: Mapping[str, str] | None = None, address_suggestion_country_to_provider: Mapping[str, str] | None = None,
*, *,
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE, weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER, provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices, filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
) -> None: ) -> None:
self._providers = tuple(providers) self._providers = tuple(providers)
self._cache = cache self._cache = cache
@@ -178,9 +192,11 @@ class AggregatorService:
self._payment_price_validation_adapter = payment_price_validation_adapter self._payment_price_validation_adapter = payment_price_validation_adapter
self._order_repository = order_repository self._order_repository = order_repository
self._order_registration_adapter = order_registration_adapter self._order_registration_adapter = order_registration_adapter
self._email_sender = email_sender
self._weight_round_scale = weight_round_scale self._weight_round_scale = weight_round_scale
self._provider_price_multiplier = provider_price_multiplier self._provider_price_multiplier = provider_price_multiplier
self._filter_and_sort_prices = filter_and_sort_prices_fn self._filter_and_sort_prices = filter_and_sort_prices_fn
self._order_uuid_factory = order_uuid_factory
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = { self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
provider.name: provider for provider in address_suggestion_providers provider.name: provider for provider in address_suggestion_providers
} }
@@ -259,17 +275,19 @@ class AggregatorService:
if self._payment_adapter is None: if self._payment_adapter is None:
raise InitPaymentUnavailableError("Payment adapter is not configured.") 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: 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=order_uuid,
amount_kopecks=request.system_data.tariff.price, amount_kopecks=request.system_data.tariff.price,
) )
except TBankPaymentRequestError as exc: except TBankPaymentRequestError as exc:
logger.exception( logger.exception(
"payment_init_rejected", "payment_init_rejected",
order_uuid=request.order_uuid, order_uuid=order_uuid,
provider_status_code=exc.status_code, provider_status_code=exc.status_code,
provider_error_code=exc.error_code, provider_error_code=exc.error_code,
provider_error_message=exc.provider_message, provider_error_message=exc.provider_message,
@@ -287,12 +305,15 @@ class AggregatorService:
"Payment initialization is temporarily unavailable." "Payment initialization is temporarily unavailable."
) from exc ) 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) return InitPaymentResponse(payment_url=payment_url)
async def _validate_init_payment_price( async def _validate_init_payment_price(
self, self,
request: InitPaymentRequest, request: InitPaymentRequest,
order_uuid: str,
) -> None: ) -> None:
if self._payment_price_validation_adapter is None: if self._payment_price_validation_adapter is None:
raise InitPaymentUnavailableError( raise InitPaymentUnavailableError(
@@ -308,7 +329,7 @@ class AggregatorService:
except ProviderRequestError as exc: except ProviderRequestError as exc:
logger.warning( logger.warning(
"init_payment_price_validation_request_rejected", "init_payment_price_validation_request_rejected",
order_uuid=request.order_uuid, order_uuid=order_uuid,
tariff_code=tariff_code, tariff_code=tariff_code,
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
error=str(exc), error=str(exc),
@@ -319,7 +340,7 @@ class AggregatorService:
except ProviderClientError as exc: except ProviderClientError as exc:
logger.warning( logger.warning(
"init_payment_price_validation_unavailable", "init_payment_price_validation_unavailable",
order_uuid=request.order_uuid, order_uuid=order_uuid,
tariff_code=tariff_code, tariff_code=tariff_code,
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
error=str(exc), error=str(exc),
@@ -330,7 +351,7 @@ class AggregatorService:
except Exception as exc: except Exception as exc:
logger.exception( logger.exception(
"init_payment_price_validation_unexpected_error", "init_payment_price_validation_unexpected_error",
order_uuid=request.order_uuid, order_uuid=order_uuid,
tariff_code=tariff_code, tariff_code=tariff_code,
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
) )
@@ -341,7 +362,7 @@ class AggregatorService:
if provider_price is None: if provider_price is None:
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=order_uuid,
tariff_code=tariff_code, tariff_code=tariff_code,
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
) )
@@ -360,7 +381,7 @@ class AggregatorService:
): ):
logger.warning( logger.warning(
"init_payment_price_mismatch", "init_payment_price_mismatch",
order_uuid=request.order_uuid, order_uuid=order_uuid,
tariff_code=tariff_code, tariff_code=tariff_code,
requested_price_kopecks=requested_price, requested_price_kopecks=requested_price,
expected_price_kopecks=expected_amount_kopecks, expected_price_kopecks=expected_amount_kopecks,
@@ -405,13 +426,64 @@ class AggregatorService:
if existing_cdek_order_uuid: if existing_cdek_order_uuid:
return "OK" 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( await self._save_cdek_order_uuid(
order_uuid=notification.OrderId, order_uuid=notification.OrderId,
cdek_order_uuid=registration_result.order_uuid, cdek_order_uuid=registration_result.order_uuid,
) )
await self._send_payment_confirmation_email(
order, order_uuid=notification.OrderId
)
return "OK" 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( async def _load_order_and_mark_payment_status(
self, self,
notification: TBankPaymentNotification, notification: TBankPaymentNotification,
@@ -464,7 +536,7 @@ class AggregatorService:
) from exc ) from exc
async def _register_cdek_order( async def _register_cdek_order(
self, order: object self, order: object, *, order_uuid: str
) -> CDEKOrderRegistrationResult: ) -> CDEKOrderRegistrationResult:
if self._order_registration_adapter is None: if self._order_registration_adapter is None:
raise TBankPaymentNotificationProcessingError( raise TBankPaymentNotificationProcessingError(
@@ -473,7 +545,9 @@ class AggregatorService:
try: try:
request = self._to_init_payment_request_from_order(order) 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: except Exception as exc:
logger.exception( logger.exception(
"cdek_order_registration_failed", "cdek_order_registration_failed",
@@ -526,6 +600,7 @@ class AggregatorService:
self, self,
*, *,
request: InitPaymentRequest, request: InitPaymentRequest,
order_uuid: str,
payment_url: str, payment_url: str,
) -> None: ) -> None:
if self._order_repository is None: if self._order_repository is None:
@@ -535,22 +610,27 @@ class AggregatorService:
async with self._order_repository.session() as session: async with self._order_repository.session() as session:
await self._order_repository.create_order( await self._order_repository.create_order(
session, 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: except Exception:
logger.exception( logger.exception(
"order_persistence_failed", "order_persistence_failed",
order_uuid=request.order_uuid, order_uuid=order_uuid,
) )
@staticmethod @staticmethod
def _to_order_data( def _to_order_data(
*, *,
request: InitPaymentRequest, request: InitPaymentRequest,
order_uuid: str,
payment_url: str, payment_url: str,
) -> OrderData: ) -> OrderData:
return OrderData( return OrderData(
order_uuid=request.order_uuid, order_uuid=order_uuid,
payment_url=payment_url, payment_url=payment_url,
price=request.system_data.tariff.price, price=request.system_data.tariff.price,
tariff_code=request.system_data.tariff.tariff_code, tariff_code=request.system_data.tariff.tariff_code,
-82
View File
@@ -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
+116
View File
@@ -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
@@ -1,9 +1,9 @@
services: services:
app: app:
image: yusupal1ev/g2s-aggregator:0.0.7 image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator container_name: g2s-aggregator
ports: ports:
- "8000:8000" - "8003:8000"
depends_on: depends_on:
redis: redis:
condition: service_started condition: service_started
@@ -13,30 +13,36 @@ services:
condition: service_completed_successfully condition: service_completed_successfully
volumes: volumes:
- ./config.yaml:/config.yaml - ./config.yaml:/config.yaml
restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator"
redis: redis:
image: redis:7-alpine image: redis:7-alpine
container_name: redis container_name: redis
command: ["redis-server", "--save", "", "--appendonly", "no"] command: ["redis-server", "--save", "", "--appendonly", "no"]
ports: ports:
- "6379:6379" - "6379:6379"
restart: unless-stopped
postgres: postgres:
image: postgres:16-alpine image: postgres:16-alpine
container_name: postgres container_name: postgres
environment: environment:
POSTGRES_DB: g2s_aggregator POSTGRES_DB: g2s_aggregator
POSTGRES_USER: g2s_user POSTGRES_USER: ${POSTGRES_USER}
POSTGRES_PASSWORD: 7ed0a5a0f24be266 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres:/var/lib/postgresql/data - postgres:/var/lib/postgresql/data
healthcheck: 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 interval: 5s
timeout: 3s timeout: 3s
retries: 10 retries: 10
migrations: migrations:
image: yusupal1ev/g2s-aggregator:0.0.7 image: gitea.p4r4dls.ru/yusupal1ev/g2s-aggregator:0.0.9
container_name: g2s-aggregator-migrations container_name: g2s-aggregator-migrations
depends_on: depends_on:
postgres: postgres:
@@ -45,8 +51,12 @@ services:
- ./config.yaml:/config.yaml - ./config.yaml:/config.yaml
command: ["poetry", "run", "alembic", "upgrade", "head"] command: ["poetry", "run", "alembic", "upgrade", "head"]
restart: "no" restart: "no"
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-migrations"
waybill-poller: 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 container_name: g2s-aggregator-waybill-poller
depends_on: depends_on:
postgres: postgres:
@@ -57,8 +67,12 @@ services:
- ./config.yaml:/config.yaml - ./config.yaml:/config.yaml
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"] command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
restart: unless-stopped restart: unless-stopped
logging:
driver: "json-file"
options:
tag: "g2s-aggregator-waybill-poller"
waybill-email-sender: 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 container_name: g2s-aggregator-waybill-email-sender
depends_on: depends_on:
postgres: postgres:
@@ -70,5 +84,20 @@ services:
command: command:
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"] ["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
restart: unless-stopped 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: volumes:
postgres: postgres:
-1
View File
@@ -17,7 +17,6 @@ POST http://localhost:8000/api/v1/delivery/order
Content-Type: application/json Content-Type: application/json
{ {
"orderUuid": "order-uuid-1",
"senderAddress": { "senderAddress": {
"cityId": 1, "cityId": 1,
"city": "Дубай", "city": "Дубай",
@@ -85,7 +85,7 @@ def test_provider_register_order_posts_payload_and_maps_response() -> None:
http_client = SequenceHTTPClient([response]) http_client = SequenceHTTPClient([response])
provider = _make_provider(http_client) 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( assert result == CDEKOrderRegistrationResult(
order_uuid="cdek-order-uuid", 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"): 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 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, 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( assert result == CDEKOrderRegistrationResult(
order_uuid="existing-cdek-order-uuid", 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"): 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 len(http_client.calls) == 2
assert sleep_calls == [0.25] 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"): 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"))
@@ -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: 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["number"] == "order-uuid-1"
assert payload["type"] == 2 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: 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" 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: 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["sender"]["phones"] == [{"number": "+79009876543"}]
assert payload["recipient"]["phones"] == [ 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: 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"] == { assert payload["from_location"] == {
"code": 7017, "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: 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] package = payload["packages"][0]
assert package["weight"] == 1000 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] package = payload["packages"][0]
assert package["weight"] == 500 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: 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 "shipment_point" not in payload
assert "delivery_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["contragent_type"] == "LEGAL_ENTITY"
assert sender["company"] == "Romashka LLC" 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: 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["comment"] == "Headphones"
assert payload["packages"][0]["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: def test_order_payload_falls_back_package_comment_to_order_uuid() -> None:
payload = map_cdek_order_request( 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" assert payload["packages"][0]["comment"] == "order-uuid-1"
def test_order_payload_includes_company_for_individual_sender_as_full_name() -> None: 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["sender"]["company"] == "Petr Petrov"
assert payload["recipient"]["company"] == "Ivan Ivanov" assert payload["recipient"]["company"] == "Ivan Ivanov"
+21
View File
@@ -164,3 +164,24 @@ def test_send_email_passes_none_when_credentials_blank() -> None:
call = send.calls[0] call = send.calls[0]
assert call["username"] is None assert call["username"] is None
assert call["password"] 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()) == []
+4 -4
View File
@@ -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" "https://example.test/api/v1/delivery/tbank/notifications"
"order-uuid-1" "order-uuid-1"
"test-password" "test-password"
"https://example.test/payment/success" "https://example.test/payment/success/order-uuid-1"
"TBankTest" "TBankTest"
).encode("utf-8") ).encode("utf-8")
).hexdigest() ).hexdigest()
@@ -109,7 +109,7 @@ def test_create_payment_link_posts_signed_payload_and_maps_payment_url() -> None
"Amount": 125000, "Amount": 125000,
"OrderId": "order-uuid-1", "OrderId": "order-uuid-1",
"NotificationURL": "https://example.test/api/v1/delivery/tbank/notifications", "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, "Token": expected_token,
}, },
"data": None, "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" "https://merchant.test/api/v1/delivery/tbank/notifications"
"order-uuid-2" "order-uuid-2"
"config-password" "config-password"
"https://merchant.test/payment/success" "https://merchant.test/payment/success/order-uuid-2"
"ConfigTerminal" "ConfigTerminal"
).encode("utf-8") ).encode("utf-8")
).hexdigest() ).hexdigest()
@@ -169,7 +169,7 @@ def test_from_config_posts_configured_urls_and_deterministic_token() -> None:
"Amount": 9900, "Amount": 9900,
"OrderId": "order-uuid-2", "OrderId": "order-uuid-2",
"NotificationURL": "https://merchant.test/api/v1/delivery/tbank/notifications", "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, "Token": expected_token,
} }
assert http_client.calls[0]["timeout"] == 6.25 assert http_client.calls[0]["timeout"] == 6.25
+1 -1
View File
@@ -76,7 +76,7 @@ def test_post_init_payment_rejects_snake_case_top_level_field() -> None:
app = create_app() app = create_app()
_install_service_override(app, service) _install_service_override(app, service)
invalid_payload = make_init_payment_payload() 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) response = _post(app, invalid_payload)
+2 -1
View File
@@ -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.""" """Return a valid camelCase JSON payload for /api/v1/delivery/order."""
payload: dict[str, Any] = { payload: dict[str, Any] = {
"orderUuid": "order-uuid-1",
"senderAddress": { "senderAddress": {
"cityId": 1, "cityId": 1,
"city": "Дубай", "city": "Дубай",
@@ -54,6 +53,8 @@ def make_init_payment_payload(**overrides: Any) -> dict[str, Any]:
"pickupDate": "2026-05-15T10:00:00.000Z", "pickupDate": "2026-05-15T10:00:00.000Z",
"deliveryDate": "2026-05-18T18:00:00.000Z", "deliveryDate": "2026-05-18T18:00:00.000Z",
"accountEmail": "client@example.com", "accountEmail": "client@example.com",
"agreePrivacy": True,
"agreeTerms": True,
"systemData": { "systemData": {
"tariff": { "tariff": {
"provider": "СДЭК", "provider": "СДЭК",
+3
View File
@@ -120,6 +120,7 @@ def test_init_payment_validates_cdek_price_before_tbank_and_returns_payment_url(
providers=[], providers=[],
payment_adapter=adapter, payment_adapter=adapter,
payment_price_validation_adapter=validation_adapter, payment_price_validation_adapter=validation_adapter,
order_uuid_factory=lambda: "order-uuid-1",
) )
result = asyncio.run(service.init_payment(request)) 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_adapter=adapter,
payment_price_validation_adapter=validation_adapter, payment_price_validation_adapter=validation_adapter,
order_repository=order_repository, order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
) )
asyncio.run(service.init_payment(request)) 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_adapter=adapter,
payment_price_validation_adapter=validation_adapter, payment_price_validation_adapter=validation_adapter,
order_repository=order_repository, order_repository=order_repository,
order_uuid_factory=lambda: "order-uuid-1",
) )
result = asyncio.run(service.init_payment(request)) result = asyncio.run(service.init_payment(request))
+122 -5
View File
@@ -34,6 +34,7 @@ class StoredOrder:
cdek_order_uuid: str | None = None cdek_order_uuid: str | None = None
cdek_waybill_uuid: str | None = None cdek_waybill_uuid: str | None = None
cdek_waybill_url: str | None = None cdek_waybill_url: str | None = None
payment_email_sent_at: object | None = None
class StubPaymentAdapter: class StubPaymentAdapter:
@@ -128,6 +129,34 @@ class StubOrderRepository:
order.cdek_order_uuid = cdek_order_uuid order.cdek_order_uuid = cdek_order_uuid
return order 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: class StubCDEKOrderAdapter:
def __init__( def __init__(
@@ -144,12 +173,12 @@ class StubCDEKOrderAdapter:
) )
] ]
self._error = error self._error = error
self.calls: list[InitPaymentRequest] = [] self.calls: list[tuple[InitPaymentRequest, str]] = []
async def register_order( async def register_order(
self, request: InitPaymentRequest self, request: InitPaymentRequest, order_uuid: str
) -> CDEKOrderRegistrationResult: ) -> CDEKOrderRegistrationResult:
self.calls.append(request) self.calls.append((request, order_uuid))
if self._error is not None: if self._error is not None:
raise self._error raise self._error
return self._responses.pop(0) 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_uuid is None
assert order.cdek_waybill_url is None assert order.cdek_waybill_url is None
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][1] == "order-uuid-1"
def test_duplicate_confirmed_notification_does_not_call_cdek() -> None: 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 result == "OK"
assert order.cdek_order_uuid == "same-cdek-order-uuid" 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",
"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"