From d7ccddc72c3b458d47c307367770f0c9864f68dd Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Mon, 11 May 2026 13:48:02 +0300 Subject: [PATCH] feat: update full path payment --- .../repositories/i_payment_repository.py | 7 +- src/application/commands/__init__.py | 2 +- ...reate_crypto_transfer_completed_command.py | 84 +++++++++++ .../create_payment_cloudkassir_command.py | 132 ------------------ .../commands/create_payment_command.py | 4 +- .../repositories/payment_repository.py | 18 ++- src/main.py | 3 +- src/presentation/dependencies/commands.py | 8 +- src/presentation/messaging/crypto_transfer.py | 12 ++ src/presentation/routing/__init__.py | 2 +- src/presentation/routing/order.py | 9 +- 11 files changed, 133 insertions(+), 148 deletions(-) create mode 100644 src/application/commands/create_crypto_transfer_completed_command.py delete mode 100644 src/application/commands/create_payment_cloudkassir_command.py diff --git a/src/application/abstractions/repositories/i_payment_repository.py b/src/application/abstractions/repositories/i_payment_repository.py index acbc99b..4a72c48 100644 --- a/src/application/abstractions/repositories/i_payment_repository.py +++ b/src/application/abstractions/repositories/i_payment_repository.py @@ -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 diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index e5be14e..05aba30 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -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 \ No newline at end of file +from src.application.commands.create_crypto_transfer_completed_command import CreateCryptoTransferCompletedCommand \ No newline at end of file diff --git a/src/application/commands/create_crypto_transfer_completed_command.py b/src/application/commands/create_crypto_transfer_completed_command.py new file mode 100644 index 0000000..8682fe1 --- /dev/null +++ b/src/application/commands/create_crypto_transfer_completed_command.py @@ -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, + ) diff --git a/src/application/commands/create_payment_cloudkassir_command.py b/src/application/commands/create_payment_cloudkassir_command.py deleted file mode 100644 index 2e38ae7..0000000 --- a/src/application/commands/create_payment_cloudkassir_command.py +++ /dev/null @@ -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, - ) diff --git a/src/application/commands/create_payment_command.py b/src/application/commands/create_payment_command.py index 14f634a..276ab9e 100644 --- a/src/application/commands/create_payment_command.py +++ b/src/application/commands/create_payment_command.py @@ -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, diff --git a/src/infrastructure/database/repositories/payment_repository.py b/src/infrastructure/database/repositories/payment_repository.py index 1ed03e1..c63dedd 100644 --- a/src/infrastructure/database/repositories/payment_repository.py +++ b/src/infrastructure/database/repositories/payment_repository.py @@ -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() diff --git a/src/main.py b/src/main.py index 76c8e49..b7747ec 100644 --- a/src/main.py +++ b/src/main.py @@ -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) diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index 67dd83e..2022151 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -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) \ No newline at end of file +) -> CreateCryptoTransferCompletedCommand: + return CreateCryptoTransferCompletedCommand(unit_of_work=unit_of_work,receipt=receipt) \ No newline at end of file diff --git a/src/presentation/messaging/crypto_transfer.py b/src/presentation/messaging/crypto_transfer.py index dc86ef8..23b671c 100644 --- a/src/presentation/messaging/crypto_transfer.py +++ b/src/presentation/messaging/crypto_transfer.py @@ -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) diff --git a/src/presentation/routing/__init__.py b/src/presentation/routing/__init__.py index b6c4034..68789f7 100644 --- a/src/presentation/routing/__init__.py +++ b/src/presentation/routing/__init__.py @@ -1 +1 @@ -from src.presentation.routing.order import order_router \ No newline at end of file +from src.presentation.routing.order import itpay_router,order_router \ No newline at end of file diff --git a/src/presentation/routing/order.py b/src/presentation/routing/order.py index 918c55e..fe4bd92 100644 --- a/src/presentation/routing/order.py +++ b/src/presentation/routing/order.py @@ -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()