fix: test command

This commit is contained in:
2026-05-09 00:14:12 +03:00
parent 22f27fa524
commit be8aee7b73
8 changed files with 126 additions and 44 deletions

View File

@@ -14,6 +14,11 @@ class IOrderRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def get_by_id(self,order_id: str) -> OrderEntity | None:
raise NotImplementedError
@abstractmethod @abstractmethod
async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity: async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
raise NotImplementedError raise NotImplementedError

View File

@@ -8,6 +8,15 @@ from src.infrastructure.database.decorators import transactional
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData 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: class CreatePaymentCloudkassirCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt): def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt):
self._unit_of_work = unit_of_work self._unit_of_work = unit_of_work
@@ -37,27 +46,62 @@ class CreatePaymentCloudkassirCommand:
user = await self._unit_of_work.user_repository.get(user_id) user = await self._unit_of_work.user_repository.get(user_id)
if user is None: if user is None:
raise ApplicationException(status_code=404, message='User not found') 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: if not email:
raise ApplicationException(status_code=400, message='User email missing') 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 phone = phone_raw if phone_raw else None
total_amount: Decimal | None = None customer_inn_meta = metadata.get('customer_inn')
if payment.amount is not None: if customer_inn_meta is None:
total_amount = Decimal(str(payment.amount)).quantize(Decimal('0.01')) customer_inn_meta = metadata.get('customerInn')
if total_amount is None: customer_inn = str(customer_inn_meta or user.inn or '').strip()
raw_amt = metadata.get('amount')
if raw_amt is not None: paid_total = _parse_money(payment.amount)
total_amount = Decimal(str(raw_amt)).quantize(Decimal('0.01')) if paid_total is None:
if total_amount is None: paid_total = _parse_money(metadata.get('amount'))
raise ApplicationException(status_code=400, message='Itpay webhook payment amount missing for receipt')
raw_sf = metadata.get('service_fee') meta_principal = _parse_money(metadata.get('principal_amount'))
if raw_sf is None: meta_agent = _parse_money(metadata.get('agent_fee'))
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing service_fee for receipt') if meta_agent is None:
service_fee = Decimal(str(raw_sf)).quantize(Decimal('0.01')) meta_agent = _parse_money(metadata.get('service_fee'))
principal_amount = (total_amount - service_fee).quantize(Decimal('0.01'))
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: if principal_amount < 0:
raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') 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( await self._receipt.create_receipt(
order_id=order_id, order_id=order_id,
user_id=user_id, user_id=user_id,
@@ -66,5 +110,6 @@ class CreatePaymentCloudkassirCommand:
principal_amount=principal_amount, principal_amount=principal_amount,
service_fee=service_fee, service_fee=service_fee,
phone=phone, phone=phone,
customer_inn=customer_inn,
request_id=str(ULID()), request_id=str(ULID()),
) )

View File

@@ -4,9 +4,11 @@ from decimal import Decimal
from typing import Any from typing import Any
from ulid import ULID from ulid import ULID
import aiohttp import aiohttp
import orjson
from aiohttp import BasicAuth, ClientTimeout from aiohttp import BasicAuth, ClientTimeout
from src.application.contracts import IReceipt from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException from src.application.domain.exceptions import ApplicationException
from src.infrastructure.cloud_kassir.constants import CLOUD_KASSIR_API_BASE_URL
class ClaudeKassirClient(IReceipt): class ClaudeKassirClient(IReceipt):
@@ -16,7 +18,7 @@ class ClaudeKassirClient(IReceipt):
public_id: str, public_id: str,
api_secret: str, api_secret: str,
inn: 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, success_url: str | None = None,
fail_url: str | None = None, fail_url: str | None = None,
timeout_seconds: float = 30, timeout_seconds: float = 30,
@@ -118,9 +120,28 @@ class ClaudeKassirClient(IReceipt):
async with aiohttp.ClientSession(timeout=self._timeout) as session: async with aiohttp.ClientSession(timeout=self._timeout) as session:
auth = BasicAuth(self._public_id, self._api_secret) auth = BasicAuth(self._public_id, self._api_secret)
async with session.post(url, json=payload, headers=headers, auth=auth) as resp: 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: 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: if body.get('Success') is False:
raise ApplicationException(status_code=409, message=str(body.get('Message') or 'Receipt provider rejected receipt')) raise ApplicationException(status_code=409, message=str(body.get('Message') or 'Receipt provider rejected receipt'))
return body return body

View File

@@ -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'

View File

@@ -96,10 +96,6 @@ class Settings(BaseSettings):
CLOUD_KASSIR_PUBLIC_ID: str = '' CLOUD_KASSIR_PUBLIC_ID: str = ''
CLOUD_KASSIR_API_SECRET: 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_LEVEL: Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] = "INFO"
LOG_FORMAT: Literal["JSON", "TEXT"] = "TEXT" 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_PUBLIC_ID'] = str(ck_public).strip()
data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip() data['CLOUD_KASSIR_API_SECRET'] = str(ck_secret).strip()
else: else:
cloudkassir = read_secret_optional('cloudkassir') cloudkassir = read_secret('cloudkassir')
if cloudkassir: ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()}
ck_ci = {str(k).lower(): v for k, v in cloudkassir.items()} public_id_ck = ck_ci.get('public_id')
public_id_ck = ck_ci.get('public_id') api_secret_ck = ck_ci.get('api_secret')
api_secret_ck = ck_ci.get('api_secret') if api_secret_ck is None:
if api_secret_ck is None: api_secret_ck = ck_ci.get('secret')
api_secret_ck = ck_ci.get('secret') missing_ck = []
if public_id_ck is not None and str(public_id_ck).strip(): if public_id_ck is None or not str(public_id_ck).strip():
data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip() missing_ck.append('public_id')
if api_secret_ck is not None and str(api_secret_ck).strip(): if api_secret_ck is None or not str(api_secret_ck).strip():
data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip() missing_ck.append('api_secret')
inn_ck = ck_ci.get('inn') if missing_ck:
if inn_ck is not None and str(inn_ck).strip(): raise RuntimeError(f'Vault secret cloudkassir missing non-empty keys: {missing_ck} (mount={mount},path=cloudkassir)')
data['CLOUD_KASSIR_INN'] = str(inn_ck).strip() data['CLOUD_KASSIR_PUBLIC_ID'] = str(public_id_ck).strip()
base_ck = ck_ci.get('api_base_url') data['CLOUD_KASSIR_API_SECRET'] = str(api_secret_ck).strip()
if base_ck is not None and str(base_ck).strip():
data['CLOUD_KASSIR_API_BASE_URL'] = str(base_ck).strip()
return data return data

View File

@@ -77,6 +77,14 @@ class OrderRepository(IOrderRepository):
return self._to_entity(model) 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: async def update_after_itpay_payment_created(self,order: OrderEntity) -> OrderEntity:
if not order.id: if not order.id:
raise ValueError('OrderEntity.id is required') raise ValueError('OrderEntity.id is required')

View File

@@ -39,6 +39,9 @@ class ItPayClient(IItPayService):
'service_fee': str(order.service_fee) if order.service_fee is not None else None, 'service_fee': str(order.service_fee) if order.service_fee is not None else None,
'amount': amount_str, '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 != ''} metadata = {k:v for k,v in metadata.items() if v is not None and v != ''}
payload: dict[str, Any] = { payload: dict[str, Any] = {
'amount': amount_str, 'amount': amount_str,

View File

@@ -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 import ICache,ILogger,IQueueMessanger,IReceipt
from src.application.contracts.i_itpay_service import IItPayService from src.application.contracts.i_itpay_service import IItPayService
from src.infrastructure.cloud_kassir import ClaudeKassirClient 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.config import settings
from src.infrastructure.itpay.client import ItPayClient from src.infrastructure.itpay.client import ItPayClient
from src.presentation.dependencies.cache import get_remote_cache from src.presentation.dependencies.cache import get_remote_cache
@@ -17,6 +18,7 @@ def get_itpay_service() -> IItPayService:
return ItPayClient( return ItPayClient(
public_id=settings.ITPAY_PUBLIC_ID, public_id=settings.ITPAY_PUBLIC_ID,
api_secret=settings.ITPAY_API_SECRET, 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( return ClaudeKassirClient(
public_id=settings.CLOUD_KASSIR_PUBLIC_ID, public_id=settings.CLOUD_KASSIR_PUBLIC_ID,
api_secret=settings.CLOUD_KASSIR_API_SECRET, api_secret=settings.CLOUD_KASSIR_API_SECRET,
inn=settings.CLOUD_KASSIR_INN, inn=CLOUD_KASSIR_INN,
api_base_url=settings.CLOUD_KASSIR_API_BASE_URL, api_base_url=CLOUD_KASSIR_API_BASE_URL,
success_url=settings.CLOUD_KASSIR_SUCCESS_URL, success_url=CLOUD_KASSIR_SUCCESS_URL,
fail_url=settings.CLOUD_KASSIR_FAIL_URL, fail_url=CLOUD_KASSIR_FAIL_URL,
) )