feat: update full path payment

This commit is contained in:
2026-05-11 13:48:02 +03:00
parent ad51f1220f
commit d7ccddc72c
11 changed files with 133 additions and 148 deletions

View File

@@ -3,7 +3,12 @@ from abc import ABC,abstractmethod
class IPaymentRepository(ABC):
@abstractmethod
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> None:
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool:
raise NotImplementedError
@abstractmethod
async def update_crypto_transfer_completed(self,*,order_id:str,web3_transaction_hash:str|None) -> None:
raise NotImplementedError

View File

@@ -1,3 +1,3 @@
from src.application.commands.create_order_command import CreateOrderCommand
from src.application.commands.create_payment_command import CreatePaymentCommand
from src.application.commands.create_payment_cloudkassir_command import CreatePaymentCloudkassirCommand
from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand

View File

@@ -0,0 +1,84 @@
from __future__ import annotations
from decimal import Decimal
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException
from src.infrastructure.database.decorators import transactional
class CreateCryptoTransferCompletedCommand:
def __init__(self, *, unit_of_work: IUnitOfWork, receipt: IReceipt):
self._unit_of_work = unit_of_work
self._receipt = receipt
@transactional
async def __call__(self, *, order_id: str, user_id: str, web3_transaction_hash: str | None = None) -> None:
if not order_id:
raise ApplicationException(status_code=400, message='Crypto transfer completed message missing order_id')
if not user_id:
raise ApplicationException(status_code=400, message='Crypto transfer completed message missing user_id')
await self._unit_of_work.payment_repository.update_crypto_transfer_completed(
order_id=order_id,
web3_transaction_hash=web3_transaction_hash,
)
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()
if not email:
raise ApplicationException(status_code=400, message='User email missing')
customer_info = ' '.join(
part
for part in (
str(user.last_name or '').strip(),
str(user.first_name or '').strip(),
str(user.middle_name or '').strip(),
)
if part
)
if not customer_info:
raise ApplicationException(status_code=400, message='User full name missing')
customer_inn = str(user.inn or '').strip()
if not customer_inn:
raise ApplicationException(status_code=400, message='User inn missing')
if user.birth_date is None:
raise ApplicationException(status_code=400, message='User birth date missing')
customer_birthday = f'{user.birth_date.isoformat()}T12:00:00.000Z'
order = await self._unit_of_work.order_repository.get_by_id(order_id)
if order is None:
raise ApplicationException(status_code=404, message='Order not found')
if order.total_price is None:
raise ApplicationException(status_code=400, message='Order total price missing for receipt')
if order.service_fee is None:
raise ApplicationException(status_code=400, message='Order service fee missing for receipt')
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'))
if principal_amount < 0:
raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative')
receipt_response = await self._receipt.create_receipt(
order_id=order_id,
user_id=user_id,
email=email,
total_amount=total_amount,
principal_amount=principal_amount,
service_fee=service_fee,
customer_info=customer_info,
customer_inn=customer_inn,
customer_birthday=customer_birthday,
request_id=str(ULID()),
)
receipt_model = receipt_response.get('Model')
if not isinstance(receipt_model, dict):
receipt_model = {}
await self._unit_of_work.payment_repository.update_receipt(
order_id=order_id,
receipt_cloudekassir_id=str(receipt_model.get('Id') or '') or None,
receipt_cloudekassir_link=str(receipt_model.get('ReceiptLocalUrl') or '') or None,
)

View File

@@ -1,132 +0,0 @@
from __future__ import annotations
from decimal import Decimal
from ulid import ULID
from src.application.abstractions import IUnitOfWork
from src.application.contracts import IReceipt
from src.application.domain.exceptions import ApplicationException
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
self._receipt = receipt
@transactional
async def __call__(self, payment: ItpayPaymentData) -> None:
if str(payment.status).strip().lower() != 'completed':
return
metadata = payment.metadata or {}
order_id = str(metadata.get('order_id') or '')
user_id = str(metadata.get('user_id') or '')
if not order_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing order_id')
if not user_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing user_id')
await self._unit_of_work.payment_repository.create_completed(
user_id=user_id,
order_id=order_id,
itpay_payment_id=str(payment.id),
itpay_paid_amount=str(payment.amount) if payment.amount is not None else None,
transaction_id=str(payment.transaction_id) if payment.transaction_id is not None else None,
paid_at=str(payment.paid) if payment.paid is not None else None,
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
)
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()
if not email:
raise ApplicationException(status_code=400, message='User email missing')
customer_info = ' '.join(
part
for part in (
str(user.last_name or '').strip(),
str(user.first_name or '').strip(),
str(user.middle_name or '').strip(),
)
if part
)
if not customer_info:
raise ApplicationException(status_code=400, message='User full name missing')
customer_inn = str(user.inn or '').strip()
if not customer_inn:
raise ApplicationException(status_code=400, message='User inn missing')
if user.birth_date is None:
raise ApplicationException(status_code=400, message='User birth date missing')
customer_birthday = f'{user.birth_date.isoformat()}T12:00:00.000Z'
paid_total = _parse_money(payment.amount)
if paid_total is None:
paid_total = _parse_money(metadata.get('amount'))
if paid_total is None:
paid_total = _parse_money(metadata.get('total_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')
receipt_response = await self._receipt.create_receipt(
order_id=order_id,
user_id=user_id,
email=email,
total_amount=total_amount,
principal_amount=principal_amount,
service_fee=service_fee,
customer_info=customer_info,
customer_inn=customer_inn,
customer_birthday=customer_birthday,
request_id=str(ULID()),
)
receipt_model = receipt_response.get('Model')
if not isinstance(receipt_model, dict):
receipt_model = {}
await self._unit_of_work.payment_repository.update_receipt(
order_id=order_id,
receipt_cloudekassir_id=str(receipt_model.get('Id') or '') or None,
receipt_cloudekassir_link=str(receipt_model.get('ReceiptLocalUrl') or '') or None,
)

View File

@@ -25,7 +25,7 @@ class CreatePaymentCommand:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing order_id')
if not user_id:
raise ApplicationException(status_code=400, message='Itpay webhook metadata missing user_id')
await self._unit_of_work.payment_repository.create_completed(
payment_created = await self._unit_of_work.payment_repository.create_completed(
user_id=user_id,
order_id=order_id,
itpay_payment_id=str(payment.id),
@@ -34,6 +34,8 @@ class CreatePaymentCommand:
paid_at=str(payment.paid) if payment.paid is not None else None,
expired_date=str(payment.expired_date) if payment.expired_date is not None else None,
)
if not payment_created:
return
message_id = str(ULID())
message: dict[str,str] = {
'order_id': order_id,

View File

@@ -16,18 +16,18 @@ class PaymentRepository(IPaymentRepository):
self._logger=logger
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> None:
async def create_completed(self,*,user_id:str,order_id:str,itpay_payment_id:str,itpay_paid_amount:str|None,transaction_id:str|None,paid_at:str|None,expired_date:str|None) -> bool:
stmt=select(Payment).where(Payment.order_id==order_id)
existing=await self._session.scalar(stmt)
if existing is not None:
return
return False
paid_at_dt=datetime.fromisoformat(paid_at.replace('Z','+00:00')) if paid_at else None
expired_dt=datetime.fromisoformat(expired_date.replace('Z','+00:00')) if expired_date else None
paid_amount_dec=Decimal(str(itpay_paid_amount)) if itpay_paid_amount is not None else None
model=Payment(
user_id=user_id,
order_id=order_id,
status=PaymentStatus.PENDING,
status=PaymentStatus.MONEY_ACCEPTED,
receipt_cloudekassir_id=None,
receipt_cloudekassir_link=None,
itpay_payment_id=itpay_payment_id,
@@ -38,6 +38,17 @@ class PaymentRepository(IPaymentRepository):
)
self._session.add(model)
await self._session.flush()
return True
async def update_crypto_transfer_completed(self,*,order_id:str,web3_transaction_hash:str|None) -> None:
stmt=select(Payment).where(Payment.order_id==order_id)
model=await self._session.scalar(stmt)
if model is None:
return
model.status=PaymentStatus.USDT_DELIVERED
model.web3_transaction_hash=web3_transaction_hash
await self._session.flush()
return
@@ -46,6 +57,7 @@ class PaymentRepository(IPaymentRepository):
model=await self._session.scalar(stmt)
if model is None:
return
model.status=PaymentStatus.COMPLETED
model.receipt_cloudekassir_id=receipt_cloudekassir_id
model.receipt_cloudekassir_link=receipt_cloudekassir_link
await self._session.flush()

View File

@@ -16,7 +16,7 @@ from src.infrastructure.config import settings
from src.presentation.handler import application_exception_handler, unhandled_exception_handler
from src.presentation.messaging import crypto_transfer_router
from src.presentation.middleware import TraceIDMiddleware, SecurityHeadersMiddleware
from src.presentation.routing import order_router
from src.presentation.routing import itpay_router,order_router
security = HTTPBasic()
@@ -74,6 +74,7 @@ app.add_exception_handler(ApplicationException, application_exception_handler)
app.add_exception_handler(Exception, unhandled_exception_handler)
app.include_router(order_router)
app.include_router(itpay_router)
app.include_router(crypto_transfer_router)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations
from fastapi import Depends
from src.application.abstractions import IUnitOfWork
from src.application.commands import CreateOrderCommand,CreatePaymentCommand,CreatePaymentCloudkassirCommand
from src.application.commands import CreateCryptoTransferCompletedCommand,CreateOrderCommand,CreatePaymentCommand
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
@@ -55,8 +55,8 @@ def get_cloud_kassir_receipt() -> IReceipt:
)
def get_create_payment_cloudkassir_command(
def get_crypto_transfer_completed_command(
unit_of_work: IUnitOfWork = Depends(get_unit_of_work),
receipt: IReceipt = Depends(get_cloud_kassir_receipt),
) -> CreatePaymentCloudkassirCommand:
return CreatePaymentCloudkassirCommand(unit_of_work=unit_of_work,receipt=receipt)
) -> CreateCryptoTransferCompletedCommand:
return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt)

View File

@@ -1,9 +1,11 @@
from fastapi import Depends
from faststream.rabbit.fastapi import RabbitMessage,RabbitRouter
from pydantic import BaseModel
from src.application.commands import CreateCryptoTransferCompletedCommand
from src.application.contracts import ILogger
from src.infrastructure.config import settings
from src.infrastructure.context_vars import trace_id_var
from src.presentation.dependencies.commands import get_crypto_transfer_completed_command
from src.presentation.dependencies.logger import get_logger
@@ -15,12 +17,16 @@ class CryptoTransferCompletedMessage(BaseModel):
order_id: str
trace_id: str
message_id: str
web3_transaction_hash: str | None = None
transaction_hash: str | None = None
tx_hash: str | None = None
@crypto_transfer_router.subscriber(settings.RABBIT_CRYPTO_TRANSFER_COMPLETED_QUEUE)
async def crypto_transfer_completed_handler(
msg_body: CryptoTransferCompletedMessage,
message: RabbitMessage,
command: CreateCryptoTransferCompletedCommand = Depends(get_crypto_transfer_completed_command),
logger: ILogger = Depends(get_logger),
) -> None:
trace_id=msg_body.trace_id
@@ -33,6 +39,12 @@ async def crypto_transfer_completed_handler(
'rabbit_message_id':message.message_id,
'rabbit_correlation_id':message.correlation_id,
})
web3_transaction_hash=msg_body.web3_transaction_hash or msg_body.transaction_hash or msg_body.tx_hash
await command(
order_id=msg_body.order_id,
user_id=msg_body.user_id,
web3_transaction_hash=web3_transaction_hash,
)
finally:
trace_id_var.reset(token)

View File

@@ -1 +1 @@
from src.presentation.routing.order import order_router
from src.presentation.routing.order import itpay_router,order_router

View File

@@ -3,18 +3,19 @@ import orjson
from fastapi import APIRouter, Depends, Request
from fastapi.responses import ORJSONResponse
from src.application.commands import CreateOrderCommand
from src.application.commands import CreatePaymentCloudkassirCommand
from src.application.commands import CreatePaymentCommand
from src.application.contracts import ILogger
from src.application.domain.dto import AuthContext
from src.application.domain.enums import OrderStatus
from src.application.domain.exceptions import ConflictException
from src.presentation.decorators import require_access_token, csrf_protect
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_cloudkassir_command
from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_command
from src.presentation.dependencies.logger import get_logger
from src.presentation.schemas.order import CreateOrder,CreateOrderResponse,ErrorResponse,OrderPaymentResponse
from src.presentation.schemas.itpay_payment_models import ItpayPaymentData
order_router = APIRouter(prefix='/order', tags=['orders'])
itpay_router = APIRouter(prefix='/itpay', tags=['itpay'])
@order_router.post(
@@ -86,10 +87,10 @@ async def create_order(
return content
@order_router.post('/webhook/itpay')
@itpay_router.post('/webhook')
async def itpay_webhook(
request: Request,
payment_command: CreatePaymentCloudkassirCommand = Depends(get_create_payment_cloudkassir_command),
payment_command: CreatePaymentCommand = Depends(get_create_payment_command),
logger: ILogger = Depends(get_logger),
) -> ORJSONResponse:
raw = await request.body()