Добавлен CI, добавлена отправка письма о получении оплаты, добавлен order_id в success_url
Deploy / deploy (push) Successful in 1m0s
Deploy / deploy (push) Successful in 1m0s
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
|
.idea
|
||||||
*.iml
|
*.iml
|
||||||
/config.yaml
|
/config.yaml
|
||||||
|
/docker-compose.yml
|
||||||
__pycache__
|
__pycache__
|
||||||
http-client.private.env.json
|
http-client.private.env.json
|
||||||
scripts
|
scripts
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,20 +95,21 @@ 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)
|
||||||
message.add_attachment(
|
if attachment_bytes is not None and attachment_filename is not None:
|
||||||
attachment_bytes,
|
message.add_attachment(
|
||||||
maintype="application",
|
attachment_bytes,
|
||||||
subtype="pdf",
|
maintype="application",
|
||||||
filename=attachment_filename,
|
subtype="pdf",
|
||||||
)
|
filename=attachment_filename,
|
||||||
|
)
|
||||||
return message
|
return message
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
|
|||||||
@@ -117,10 +117,10 @@ class WaybillEmailSenderService:
|
|||||||
try:
|
try:
|
||||||
summary = await self.poll_once()
|
summary = await self.poll_once()
|
||||||
logger.info(
|
logger.info(
|
||||||
"waybill_email_tick",
|
"waybill_email_tick "
|
||||||
processed=summary.processed,
|
f"processed={summary.processed} "
|
||||||
succeeded=summary.succeeded,
|
f"succeeded={summary.succeeded} "
|
||||||
failed=summary.failed,
|
f"failed={summary.failed}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("waybill_email_tick_failed")
|
logger.exception("waybill_email_tick_failed")
|
||||||
|
|||||||
@@ -116,10 +116,10 @@ class WaybillPollerService:
|
|||||||
try:
|
try:
|
||||||
summary = await self.poll_once()
|
summary = await self.poll_once()
|
||||||
logger.info(
|
logger.info(
|
||||||
"waybill_poll_tick",
|
"waybill_poll_tick "
|
||||||
processed=summary.processed,
|
f"processed={summary.processed} "
|
||||||
succeeded=summary.succeeded,
|
f"succeeded={summary.succeeded} "
|
||||||
failed=summary.failed,
|
f"failed={summary.failed}"
|
||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("waybill_poll_tick_failed")
|
logger.exception("waybill_poll_tick_failed")
|
||||||
|
|||||||
@@ -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:
|
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:
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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()) == []
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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": "СДЭК",
|
||||||
|
|||||||
@@ -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))
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user