Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 1m22s
Deploy / deploy (push) Successful in 1m22s
This commit is contained in:
@@ -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,6 +1,7 @@
|
||||
.idea
|
||||
*.iml
|
||||
/config.yaml
|
||||
/docker-compose.yml
|
||||
__pycache__
|
||||
http-client.private.env.json
|
||||
scripts
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.")
|
||||
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
self, request: InitPaymentRequest, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
payload = map_cdek_order_request(request)
|
||||
payload = map_cdek_order_request(request, order_uuid)
|
||||
for attempt in range(self._retry_attempts + 1):
|
||||
try:
|
||||
token = await self._auth_client.get_access_token()
|
||||
@@ -490,9 +490,9 @@ class CDEKProvider(DeliveryProvider):
|
||||
) from exc
|
||||
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
self, request: InitPaymentRequest, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
return await self._client.register_order(request)
|
||||
return await self._client.register_order(request, order_uuid)
|
||||
|
||||
async def get_order(self, cdek_order_uuid: str) -> CDEKOrderInfo:
|
||||
return await self._client.get_order(cdek_order_uuid)
|
||||
|
||||
@@ -42,9 +42,11 @@ class CDEKWaybillInfo:
|
||||
url: str | None
|
||||
|
||||
|
||||
def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
def map_cdek_order_request(
|
||||
request: InitPaymentRequest, order_uuid: str
|
||||
) -> dict[str, Any]:
|
||||
payload: dict[str, Any] = {
|
||||
"number": request.order_uuid,
|
||||
"number": order_uuid,
|
||||
"type": 2,
|
||||
"tariff_code": request.system_data.tariff.tariff_code,
|
||||
"print": _CDEK_WAYBILL_PRINT_TYPE,
|
||||
@@ -52,7 +54,7 @@ def map_cdek_order_request(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
"recipient": _map_party(request.receiver_contact),
|
||||
"from_location": _map_location(request.sender_address),
|
||||
"to_location": _map_location(request.receiver_address),
|
||||
"packages": [_map_package(request)],
|
||||
"packages": [_map_package(request, order_uuid)],
|
||||
}
|
||||
if request.content.description:
|
||||
payload["comment"] = request.content.description
|
||||
@@ -280,12 +282,12 @@ def _map_location(address: Address) -> dict[str, Any]:
|
||||
}
|
||||
|
||||
|
||||
def _map_package(request: InitPaymentRequest) -> dict[str, Any]:
|
||||
def _map_package(request: InitPaymentRequest, order_uuid: str) -> dict[str, Any]:
|
||||
system_data: SystemData = request.system_data
|
||||
weight_grams = kilograms_string_to_grams(request.system_data.weight)
|
||||
description = request.content.description or request.order_uuid
|
||||
description = request.content.description or order_uuid
|
||||
package: dict[str, Any] = {
|
||||
"number": request.order_uuid,
|
||||
"number": order_uuid,
|
||||
"weight": weight_grams,
|
||||
"comment": description,
|
||||
}
|
||||
|
||||
@@ -56,8 +56,8 @@ class SMTPEmailSender:
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
attachment_bytes: bytes,
|
||||
attachment_filename: str,
|
||||
attachment_bytes: bytes | None = None,
|
||||
attachment_filename: str | None = None,
|
||||
) -> None:
|
||||
message = self._build_message(
|
||||
to=to,
|
||||
@@ -95,20 +95,21 @@ class SMTPEmailSender:
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
attachment_bytes: bytes,
|
||||
attachment_filename: str,
|
||||
attachment_bytes: bytes | None = None,
|
||||
attachment_filename: str | None = None,
|
||||
) -> EmailMessage:
|
||||
message = EmailMessage()
|
||||
message["From"] = self._from_address
|
||||
message["To"] = to
|
||||
message["Subject"] = subject
|
||||
message.set_content(body)
|
||||
message.add_attachment(
|
||||
attachment_bytes,
|
||||
maintype="application",
|
||||
subtype="pdf",
|
||||
filename=attachment_filename,
|
||||
)
|
||||
if attachment_bytes is not None and attachment_filename is not None:
|
||||
message.add_attachment(
|
||||
attachment_bytes,
|
||||
maintype="application",
|
||||
subtype="pdf",
|
||||
filename=attachment_filename,
|
||||
)
|
||||
return message
|
||||
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ class TBankAdapter:
|
||||
"Amount": amount_kopecks,
|
||||
"OrderId": order_uuid,
|
||||
"NotificationURL": self._notification_url,
|
||||
"SuccessURL": self._success_url,
|
||||
"SuccessURL": f"{self._success_url}/{order_uuid}",
|
||||
}
|
||||
payload["Token"] = _build_tbank_token(payload, password=self._password)
|
||||
return payload
|
||||
|
||||
@@ -10,6 +10,7 @@ from app.adapters.address_suggestions.yandex_geosuggest import (
|
||||
YandexGeosuggestAddressSuggestionProvider,
|
||||
)
|
||||
from app.adapters.delivery_providers.cdek import CDEKProvider
|
||||
from app.adapters.email import SMTPEmailSender
|
||||
from app.adapters.tbank import TBankAdapter
|
||||
from app.config import Settings
|
||||
from app.controllers.http_client import build_controller_http_client
|
||||
@@ -70,6 +71,15 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
postgres_engine = create_postgres_engine(settings.postgres)
|
||||
postgres_session_factory = create_postgres_session_factory(postgres_engine)
|
||||
order_repository = OrderRepository(session_factory=postgres_session_factory)
|
||||
email_sender = SMTPEmailSender(
|
||||
smtp_host=settings.email.smtp_host,
|
||||
smtp_port=settings.email.smtp_port,
|
||||
username=settings.email.username,
|
||||
password=settings.email.password,
|
||||
from_address=settings.email.from_address,
|
||||
use_tls=settings.email.use_tls,
|
||||
timeout_seconds=settings.email.timeout_seconds,
|
||||
)
|
||||
service = AggregatorService(
|
||||
providers=providers,
|
||||
cache=cache,
|
||||
@@ -77,6 +87,7 @@ def _build_aggregator_service(settings: Settings) -> AggregatorService:
|
||||
payment_price_validation_adapter=cdek_provider,
|
||||
order_repository=order_repository,
|
||||
order_registration_adapter=cdek_provider,
|
||||
email_sender=email_sender,
|
||||
address_suggestion_providers=(
|
||||
dadata_provider,
|
||||
yandex_geosuggest_provider,
|
||||
|
||||
@@ -48,6 +48,10 @@ class Order(Base):
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
payment_email_sent_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
)
|
||||
waybill_email_sent_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime(timezone=True),
|
||||
nullable=True,
|
||||
|
||||
@@ -145,6 +145,22 @@ class OrderRepository:
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
async def record_payment_email_sent(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
*,
|
||||
order_uuid: str,
|
||||
sent_at: datetime,
|
||||
) -> Order | None:
|
||||
order = await self.get_order_by_order_uuid(session, order_uuid)
|
||||
if order is None:
|
||||
return None
|
||||
|
||||
if order.payment_email_sent_at is None:
|
||||
order.payment_email_sent_at = sent_at
|
||||
await session.flush()
|
||||
return order
|
||||
|
||||
async def list_orders_pending_waybill_email(
|
||||
self,
|
||||
session: AsyncSession,
|
||||
|
||||
@@ -48,6 +48,7 @@ class Contact(_CamelModel):
|
||||
email: str | None = None
|
||||
phone: str = Field(min_length=1)
|
||||
phone_ext: str | None = None
|
||||
has_extra_phone: str | None = None
|
||||
is_company: bool
|
||||
company_name: str | None = None
|
||||
inn: str | None = None
|
||||
@@ -114,7 +115,6 @@ class SystemData(_CamelModel):
|
||||
|
||||
|
||||
class InitPaymentRequest(_CamelModel):
|
||||
order_uuid: str = Field(min_length=1)
|
||||
sender_address: Address
|
||||
sender_contact: Contact
|
||||
receiver_address: Address
|
||||
@@ -124,6 +124,8 @@ class InitPaymentRequest(_CamelModel):
|
||||
delivery_date: datetime | None = None
|
||||
account_email: EmailStr
|
||||
system_data: SystemData
|
||||
agree_privacy: bool
|
||||
agree_terms: bool
|
||||
|
||||
|
||||
class InitPaymentResponse(BaseModel):
|
||||
|
||||
+97
-17
@@ -3,10 +3,12 @@
|
||||
import asyncio
|
||||
import hashlib
|
||||
import json
|
||||
from collections.abc import Iterable, Mapping, Sequence
|
||||
from collections.abc import Callable, Iterable, Mapping, Sequence
|
||||
from contextlib import AbstractAsyncContextManager
|
||||
from datetime import datetime, timezone
|
||||
from decimal import Decimal
|
||||
from typing import Protocol
|
||||
from uuid import uuid4
|
||||
|
||||
import structlog
|
||||
|
||||
@@ -113,7 +115,7 @@ class PaymentPriceValidationAdapterProtocol(Protocol):
|
||||
|
||||
class OrderRegistrationAdapterProtocol(Protocol):
|
||||
async def register_order(
|
||||
self, request: InitPaymentRequest
|
||||
self, request: InitPaymentRequest, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult: ...
|
||||
|
||||
|
||||
@@ -144,6 +146,16 @@ class OrderRepositoryProtocol(Protocol):
|
||||
) -> object | None: ...
|
||||
|
||||
|
||||
class EmailSenderProtocol(Protocol):
|
||||
async def send_email(
|
||||
self,
|
||||
*,
|
||||
to: str,
|
||||
subject: str,
|
||||
body: str,
|
||||
) -> None: ...
|
||||
|
||||
|
||||
class FilterAndSortPricesFn(Protocol):
|
||||
def __call__(
|
||||
self,
|
||||
@@ -165,12 +177,14 @@ class AggregatorService:
|
||||
) = None,
|
||||
order_repository: OrderRepositoryProtocol | None = None,
|
||||
order_registration_adapter: OrderRegistrationAdapterProtocol | None = None,
|
||||
email_sender: EmailSenderProtocol | None = None,
|
||||
address_suggestion_providers: Sequence[AddressSuggestionProvider] = (),
|
||||
address_suggestion_country_to_provider: Mapping[str, str] | None = None,
|
||||
*,
|
||||
weight_round_scale: int = DEFAULT_WEIGHT_ROUND_SCALE,
|
||||
provider_price_multiplier: Decimal = DEFAULT_PROVIDER_PRICE_MULTIPLIER,
|
||||
filter_and_sort_prices_fn: FilterAndSortPricesFn = filter_and_sort_prices,
|
||||
order_uuid_factory: Callable[[], str] = lambda: str(uuid4()),
|
||||
) -> None:
|
||||
self._providers = tuple(providers)
|
||||
self._cache = cache
|
||||
@@ -178,9 +192,11 @@ class AggregatorService:
|
||||
self._payment_price_validation_adapter = payment_price_validation_adapter
|
||||
self._order_repository = order_repository
|
||||
self._order_registration_adapter = order_registration_adapter
|
||||
self._email_sender = email_sender
|
||||
self._weight_round_scale = weight_round_scale
|
||||
self._provider_price_multiplier = provider_price_multiplier
|
||||
self._filter_and_sort_prices = filter_and_sort_prices_fn
|
||||
self._order_uuid_factory = order_uuid_factory
|
||||
self._address_suggestion_providers: dict[str, AddressSuggestionProvider] = {
|
||||
provider.name: provider for provider in address_suggestion_providers
|
||||
}
|
||||
@@ -259,17 +275,19 @@ class AggregatorService:
|
||||
if self._payment_adapter is None:
|
||||
raise InitPaymentUnavailableError("Payment adapter is not configured.")
|
||||
|
||||
await self._validate_init_payment_price(request)
|
||||
order_uuid = self._order_uuid_factory()
|
||||
|
||||
await self._validate_init_payment_price(request, order_uuid)
|
||||
|
||||
try:
|
||||
payment_url = await self._payment_adapter.create_payment_link(
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
amount_kopecks=request.system_data.tariff.price,
|
||||
)
|
||||
except TBankPaymentRequestError as exc:
|
||||
logger.exception(
|
||||
"payment_init_rejected",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
provider_status_code=exc.status_code,
|
||||
provider_error_code=exc.error_code,
|
||||
provider_error_message=exc.provider_message,
|
||||
@@ -287,12 +305,15 @@ class AggregatorService:
|
||||
"Payment initialization is temporarily unavailable."
|
||||
) from exc
|
||||
|
||||
await self._persist_order(request=request, payment_url=payment_url)
|
||||
await self._persist_order(
|
||||
request=request, order_uuid=order_uuid, payment_url=payment_url
|
||||
)
|
||||
return InitPaymentResponse(payment_url=payment_url)
|
||||
|
||||
async def _validate_init_payment_price(
|
||||
self,
|
||||
request: InitPaymentRequest,
|
||||
order_uuid: str,
|
||||
) -> None:
|
||||
if self._payment_price_validation_adapter is None:
|
||||
raise InitPaymentUnavailableError(
|
||||
@@ -308,7 +329,7 @@ class AggregatorService:
|
||||
except ProviderRequestError as exc:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_request_rejected",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
error=str(exc),
|
||||
@@ -319,7 +340,7 @@ class AggregatorService:
|
||||
except ProviderClientError as exc:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_unavailable",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
error=str(exc),
|
||||
@@ -330,7 +351,7 @@ class AggregatorService:
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"init_payment_price_validation_unexpected_error",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
)
|
||||
@@ -341,7 +362,7 @@ class AggregatorService:
|
||||
if provider_price is None:
|
||||
logger.warning(
|
||||
"init_payment_price_validation_tariff_not_found",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
)
|
||||
@@ -360,7 +381,7 @@ class AggregatorService:
|
||||
):
|
||||
logger.warning(
|
||||
"init_payment_price_mismatch",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
tariff_code=tariff_code,
|
||||
requested_price_kopecks=requested_price,
|
||||
expected_price_kopecks=expected_amount_kopecks,
|
||||
@@ -405,13 +426,64 @@ class AggregatorService:
|
||||
if existing_cdek_order_uuid:
|
||||
return "OK"
|
||||
|
||||
registration_result = await self._register_cdek_order(order)
|
||||
registration_result = await self._register_cdek_order(
|
||||
order, order_uuid=notification.OrderId
|
||||
)
|
||||
await self._save_cdek_order_uuid(
|
||||
order_uuid=notification.OrderId,
|
||||
cdek_order_uuid=registration_result.order_uuid,
|
||||
)
|
||||
await self._send_payment_confirmation_email(
|
||||
order, order_uuid=notification.OrderId
|
||||
)
|
||||
return "OK"
|
||||
|
||||
async def _send_payment_confirmation_email(
|
||||
self,
|
||||
order: object,
|
||||
*,
|
||||
order_uuid: str,
|
||||
) -> None:
|
||||
if self._email_sender is None or self._order_repository is None:
|
||||
return
|
||||
|
||||
if getattr(order, "payment_email_sent_at", None) is not None:
|
||||
return
|
||||
|
||||
account_email = getattr(order, "account_email", None)
|
||||
if account_email is None:
|
||||
return
|
||||
|
||||
try:
|
||||
await self._email_sender.send_email(
|
||||
to=account_email,
|
||||
subject=f"Оплата по заказу {order_uuid} принята",
|
||||
body=(
|
||||
f"Здравствуйте!\n\n"
|
||||
f"Оплата по заказу {order_uuid} успешно принята.\n"
|
||||
f"Накладная будет отправлена на этот адрес в ближайшее время.\n\n"
|
||||
f"Спасибо за заказ!"
|
||||
),
|
||||
)
|
||||
async with self._order_repository.session() as session:
|
||||
await self._order_repository.record_payment_email_sent(
|
||||
session,
|
||||
order_uuid=order_uuid,
|
||||
sent_at=datetime.now(timezone.utc),
|
||||
)
|
||||
logger.info(
|
||||
"payment_confirmation_email_sent",
|
||||
order_uuid=order_uuid,
|
||||
account_email=account_email,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"payment_confirmation_email_failed",
|
||||
order_uuid=order_uuid,
|
||||
account_email=account_email,
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
async def _load_order_and_mark_payment_status(
|
||||
self,
|
||||
notification: TBankPaymentNotification,
|
||||
@@ -464,7 +536,7 @@ class AggregatorService:
|
||||
) from exc
|
||||
|
||||
async def _register_cdek_order(
|
||||
self, order: object
|
||||
self, order: object, *, order_uuid: str
|
||||
) -> CDEKOrderRegistrationResult:
|
||||
if self._order_registration_adapter is None:
|
||||
raise TBankPaymentNotificationProcessingError(
|
||||
@@ -473,7 +545,9 @@ class AggregatorService:
|
||||
|
||||
try:
|
||||
request = self._to_init_payment_request_from_order(order)
|
||||
return await self._order_registration_adapter.register_order(request)
|
||||
return await self._order_registration_adapter.register_order(
|
||||
request, order_uuid
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception(
|
||||
"cdek_order_registration_failed",
|
||||
@@ -526,6 +600,7 @@ class AggregatorService:
|
||||
self,
|
||||
*,
|
||||
request: InitPaymentRequest,
|
||||
order_uuid: str,
|
||||
payment_url: str,
|
||||
) -> None:
|
||||
if self._order_repository is None:
|
||||
@@ -535,22 +610,27 @@ class AggregatorService:
|
||||
async with self._order_repository.session() as session:
|
||||
await self._order_repository.create_order(
|
||||
session,
|
||||
self._to_order_data(request=request, payment_url=payment_url),
|
||||
self._to_order_data(
|
||||
request=request,
|
||||
order_uuid=order_uuid,
|
||||
payment_url=payment_url,
|
||||
),
|
||||
)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"order_persistence_failed",
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _to_order_data(
|
||||
*,
|
||||
request: InitPaymentRequest,
|
||||
order_uuid: str,
|
||||
payment_url: str,
|
||||
) -> OrderData:
|
||||
return OrderData(
|
||||
order_uuid=request.order_uuid,
|
||||
order_uuid=order_uuid,
|
||||
payment_url=payment_url,
|
||||
price=request.system_data.tariff.price,
|
||||
tariff_code=request.system_data.tariff.tariff_code,
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
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:
|
||||
@@ -46,7 +52,7 @@ services:
|
||||
command: ["poetry", "run", "alembic", "upgrade", "head"]
|
||||
restart: "no"
|
||||
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:
|
||||
@@ -58,7 +64,7 @@ services:
|
||||
command: ["poetry", "run", "python", "-m", "app.workers.waybill_poller"]
|
||||
restart: unless-stopped
|
||||
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 +76,16 @@ services:
|
||||
command:
|
||||
["poetry", "run", "python", "-m", "app.workers.waybill_email_sender"]
|
||||
restart: unless-stopped
|
||||
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:
|
||||
@@ -17,7 +17,6 @@ POST http://localhost:8000/api/v1/delivery/order
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"orderUuid": "order-uuid-1",
|
||||
"senderAddress": {
|
||||
"cityId": 1,
|
||||
"city": "Дубай",
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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()) == []
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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": "СДЭК",
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user