fix: test command
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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()),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
4
src/infrastructure/cloud_kassir/constants.py
Normal file
4
src/infrastructure/cloud_kassir/constants.py
Normal 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'
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user