From be8aee7b732e35748407a1ebf07a869e2635f3b1 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Sat, 9 May 2026 00:14:12 +0300 Subject: [PATCH] fix: test command --- .../repositories/i_order_repository.py | 5 ++ .../create_payment_cloudkassir_command.py | 77 +++++++++++++++---- src/infrastructure/cloud_kassir/client.py | 27 ++++++- src/infrastructure/cloud_kassir/constants.py | 4 + src/infrastructure/config/settings.py | 36 ++++----- .../database/repositories/order_repository.py | 8 ++ src/infrastructure/itpay/client.py | 3 + src/presentation/dependencies/commands.py | 10 ++- 8 files changed, 126 insertions(+), 44 deletions(-) create mode 100644 src/infrastructure/cloud_kassir/constants.py diff --git a/src/application/abstractions/repositories/i_order_repository.py b/src/application/abstractions/repositories/i_order_repository.py index 38527de..c986479 100644 --- a/src/application/abstractions/repositories/i_order_repository.py +++ b/src/application/abstractions/repositories/i_order_repository.py @@ -14,6 +14,11 @@ class IOrderRepository(ABC): raise NotImplementedError + @abstractmethod + async def get_by_id(self,order_id: str) -> OrderEntity | None: + raise NotImplementedError + + @abstractmethod async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: raise NotImplementedError diff --git a/src/application/commands/create_payment_cloudkassir_command.py b/src/application/commands/create_payment_cloudkassir_command.py index 326cc93..e47a731 100644 --- a/src/application/commands/create_payment_cloudkassir_command.py +++ b/src/application/commands/create_payment_cloudkassir_command.py @@ -8,6 +8,15 @@ from src.infrastructure.database.decorators import transactional from src.presentation.schemas.itpay_payment_models import ItpayPaymentData +def _parse_money(val: object | None) -> Decimal | None: + if val is None: + return None + s = str(val).strip() + if not s: + return None + return Decimal(s).quantize(Decimal('0.01')) + + class CreatePaymentCloudkassirCommand: def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt): self._unit_of_work = unit_of_work @@ -37,27 +46,62 @@ class CreatePaymentCloudkassirCommand: user = await self._unit_of_work.user_repository.get(user_id) if user is None: raise ApplicationException(status_code=404, message='User not found') - email = str(user.email or '').strip() + email_meta = metadata.get('customer_email') + if email_meta is None: + email_meta = metadata.get('email') + email = str(email_meta or user.email or '').strip() if not email: raise ApplicationException(status_code=400, message='User email missing') - phone_raw = (user.phone or '').strip() + phone_raw = str(metadata.get('phone') or '').strip() + if not phone_raw: + phone_raw = str(user.phone or '').strip() phone = phone_raw if phone_raw else None - total_amount: Decimal | None = None - if payment.amount is not None: - total_amount = Decimal(str(payment.amount)).quantize(Decimal('0.01')) - if total_amount is None: - raw_amt = metadata.get('amount') - if raw_amt is not None: - total_amount = Decimal(str(raw_amt)).quantize(Decimal('0.01')) - if total_amount is None: - raise ApplicationException(status_code=400, message='Itpay webhook payment amount missing for receipt') - raw_sf = metadata.get('service_fee') - if raw_sf is None: - raise ApplicationException(status_code=400, message='Itpay webhook metadata missing service_fee for receipt') - service_fee = Decimal(str(raw_sf)).quantize(Decimal('0.01')) - principal_amount = (total_amount - service_fee).quantize(Decimal('0.01')) + customer_inn_meta = metadata.get('customer_inn') + if customer_inn_meta is None: + customer_inn_meta = metadata.get('customerInn') + customer_inn = str(customer_inn_meta or user.inn or '').strip() + + paid_total = _parse_money(payment.amount) + if paid_total is None: + paid_total = _parse_money(metadata.get('amount')) + + meta_principal = _parse_money(metadata.get('principal_amount')) + meta_agent = _parse_money(metadata.get('agent_fee')) + if meta_agent is None: + meta_agent = _parse_money(metadata.get('service_fee')) + + principal_amount: Decimal + service_fee: Decimal + total_amount: Decimal + + if meta_principal is not None and meta_agent is not None: + principal_amount = meta_principal + service_fee = meta_agent + total_amount = (principal_amount + service_fee).quantize(Decimal('0.01')) + else: + order = await self._unit_of_work.order_repository.get_by_id(order_id) + if order is not None and order.total_price is not None and order.service_fee is not None: + total_amount = Decimal(str(order.total_price)).quantize(Decimal('0.01')) + service_fee = Decimal(str(order.service_fee)).quantize(Decimal('0.01')) + principal_amount = (total_amount - service_fee).quantize(Decimal('0.01')) + else: + if paid_total is None: + raise ApplicationException(status_code=400, message='Payment amount missing for receipt') + raw_sf = metadata.get('service_fee') + if raw_sf is None: + raise ApplicationException( + status_code=400, + message='Receipt amounts: need principal_amount+agent_fee in metadata, order in DB, or service_fee with paid amount', + ) + service_fee = Decimal(str(raw_sf)).quantize(Decimal('0.01')) + total_amount = paid_total + principal_amount = (total_amount - service_fee).quantize(Decimal('0.01')) + if principal_amount < 0: raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') + if paid_total is not None and abs(total_amount - paid_total) > Decimal('0.02'): + raise ApplicationException(status_code=400, message='Receipt total does not match paid amount') + await self._receipt.create_receipt( order_id=order_id, user_id=user_id, @@ -66,5 +110,6 @@ class CreatePaymentCloudkassirCommand: principal_amount=principal_amount, service_fee=service_fee, phone=phone, + customer_inn=customer_inn, request_id=str(ULID()), ) diff --git a/src/infrastructure/cloud_kassir/client.py b/src/infrastructure/cloud_kassir/client.py index 004cb29..90f7e34 100644 --- a/src/infrastructure/cloud_kassir/client.py +++ b/src/infrastructure/cloud_kassir/client.py @@ -4,9 +4,11 @@ from decimal import Decimal from typing import Any from ulid import ULID import aiohttp +import orjson from aiohttp import BasicAuth, ClientTimeout from src.application.contracts import IReceipt from src.application.domain.exceptions import ApplicationException +from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL class ClaudeKassirClient(IReceipt): @@ -16,7 +18,7 @@ class ClaudeKassirClient(IReceipt): public_id: str, api_secret: str, inn: str, - api_base_url: str = 'https://api.cloudpayments.ru', + api_base_url: str = CLOUD_KASSIR_API_BASE_URL, success_url: str | None = None, fail_url: str | None = None, timeout_seconds: float = 30, @@ -118,9 +120,28 @@ class ClaudeKassirClient(IReceipt): async with aiohttp.ClientSession(timeout=self._timeout) as session: auth = BasicAuth(self._public_id, self._api_secret) async with session.post(url, json=payload, headers=headers, auth=auth) as resp: - body = await resp.json(content_type=None) + raw = (await resp.text()).strip() + if not raw: + raise ApplicationException( + status_code=502, + message=f'Receipt provider empty response (HTTP {resp.status})', + ) + try: + parsed: Any = orjson.loads(raw) + except orjson.JSONDecodeError: + preview = raw[:240].replace('\n', ' ') + raise ApplicationException( + status_code=502, + message=f'Receipt provider non-JSON response (HTTP {resp.status}): {preview}', + ) + if not isinstance(parsed, dict): + raise ApplicationException(status_code=502, message='Receipt provider invalid response') + body = parsed if resp.status >= 400: - raise ApplicationException(status_code=502, message='Receipt provider error') + raise ApplicationException( + status_code=502, + message=str(body.get('Message') or 'Receipt provider error'), + ) if body.get('Success') is False: raise ApplicationException(status_code=409, message=str(body.get('Message') or 'Receipt provider rejected receipt')) return body diff --git a/src/infrastructure/cloud_kassir/constants.py b/src/infrastructure/cloud_kassir/constants.py new file mode 100644 index 0000000..6f4ff88 --- /dev/null +++ b/src/infrastructure/cloud_kassir/constants.py @@ -0,0 +1,4 @@ +CLOUD_KASSIR_API_BASE_URL = 'https://api.cloudpayments.ru' +CLOUD_KASSIR_INN = '9810001062' +CLOUD_KASSIR_SUCCESS_URL = 'https://yourdomain.com/success' +CLOUD_KASSIR_FAIL_URL = 'https://yourdomain.com/fail' diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index a816555..5ea186f 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -96,10 +96,6 @@ class Settings(BaseSettings): CLOUD_KASSIR_PUBLIC_ID: str = '' CLOUD_KASSIR_API_SECRET: str = '' - CLOUD_KASSIR_INN: str = '' - CLOUD_KASSIR_API_BASE_URL: str = 'https://api.cloudpayments.ru' - CLOUD_KASSIR_SUCCESS_URL: str | None = None - CLOUD_KASSIR_FAIL_URL: str | None = None LOG_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO" LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT" @@ -276,23 +272,21 @@ class Settings(BaseSettings): data['CLOUD_KASSIR_PUBLIC_ID'] = str(ck_public).strip() data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip() else: - cloudkassir = read_secret_optional('cloudkassir') - if cloudkassir: - ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()} - public_id_ck = ck_ci.get('public_id') - api_secret_ck = ck_ci.get('api_secret') - if api_secret_ck is None: - api_secret_ck = ck_ci.get('secret') - if public_id_ck is not None and str(public_id_ck).strip(): - data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip() - if api_secret_ck is not None and str(api_secret_ck).strip(): - data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip() - inn_ck = ck_ci.get('inn') - if inn_ck is not None and str(inn_ck).strip(): - data['CLOUD_KASSIR_INN'] = str(inn_ck).strip() - base_ck = ck_ci.get('api_base_url') - if base_ck is not None and str(base_ck).strip(): - data['CLOUD_KASSIR_API_BASE_URL'] = str(base_ck).strip() + cloudkassir = read_secret('cloudkassir') + ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()} + public_id_ck = ck_ci.get('public_id') + api_secret_ck = ck_ci.get('api_secret') + if api_secret_ck is None: + api_secret_ck = ck_ci.get('secret') + missing_ck = [] + if public_id_ck is None or not str(public_id_ck).strip(): + missing_ck.append('public_id') + if api_secret_ck is None or not str(api_secret_ck).strip(): + missing_ck.append('api_secret') + if missing_ck: + raise RuntimeError(f'Vault secret cloudkassir missing non-empty keys: {missing_ck} (mount={mount},path=cloudkassir)') + data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip() + data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip() return data diff --git a/src/infrastructure/database/repositories/order_repository.py b/src/infrastructure/database/repositories/order_repository.py index fb135ce..2c42159 100644 --- a/src/infrastructure/database/repositories/order_repository.py +++ b/src/infrastructure/database/repositories/order_repository.py @@ -77,6 +77,14 @@ class OrderRepository(IOrderRepository): return self._to_entity(model) + async def get_by_id(self,order_id: str) -> OrderEntity | None: + stmt=select(Order).where(Order.id==order_id) + model=await self._session.scalar(stmt) + if model is None: + return None + return self._to_entity(model) + + async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: if not order.id: raise ValueError('OrderEntity.id is required') diff --git a/src/infrastructure/itpay/client.py b/src/infrastructure/itpay/client.py index f17ccdb..293daf9 100644 --- a/src/infrastructure/itpay/client.py +++ b/src/infrastructure/itpay/client.py @@ -39,6 +39,9 @@ class ItPayClient(IItPayService): 'service_fee': str(order.service_fee) if order.service_fee is not None else None, 'amount': amount_str, } + if order.total_price is not None and order.service_fee is not None: + principal = (Decimal(str(order.total_price)) - Decimal(str(order.service_fee))).quantize(Decimal('0.01')) + metadata['principal_amount'] = str(principal) metadata = {k:v for k,v in metadata.items() if v is not None and v != ''} payload: dict[str, Any] = { 'amount': amount_str, diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index f635d5d..67dd83e 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -5,6 +5,7 @@ from src.application.commands import CreateOrderCommand,CreatePaymentCommand,Cre from src.application.contracts import ICache,ILogger,IQueueMessanger,IReceipt from src.application.contracts.i_itpay_service import IItPayService from src.infrastructure.cloud_kassir import ClaudeKassirClient +from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL,CLOUD_KASSIR_FAIL_URL,CLOUD_KASSIR_INN,CLOUD_KASSIR_SUCCESS_URL from src.infrastructure.config import settings from src.infrastructure.itpay.client import ItPayClient from src.presentation.dependencies.cache import get_remote_cache @@ -17,6 +18,7 @@ def get_itpay_service() -> IItPayService: return ItPayClient( public_id=settings.ITPAY_PUBLIC_ID, api_secret=settings.ITPAY_API_SECRET, + api_base_url='https://api.gw.itpay.ru', ) @@ -46,10 +48,10 @@ def get_cloud_kassir_receipt() -> IReceipt: return ClaudeKassirClient( public_id=settings.CLOUD_KASSIR_PUBLIC_ID, api_secret=settings.CLOUD_KASSIR_API_SECRET, - inn=settings.CLOUD_KASSIR_INN, - api_base_url=settings.CLOUD_KASSIR_API_BASE_URL, - success_url=settings.CLOUD_KASSIR_SUCCESS_URL, - fail_url=settings.CLOUD_KASSIR_FAIL_URL, + inn=CLOUD_KASSIR_INN, + api_base_url=CLOUD_KASSIR_API_BASE_URL, + success_url=CLOUD_KASSIR_SUCCESS_URL, + fail_url=CLOUD_KASSIR_FAIL_URL, )