From 22f27fa524c5ad8194969fa64002af1f51ec9398 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Sat, 9 May 2026 00:02:33 +0300 Subject: [PATCH] feat: add tests command --- .../abstractions/i_unit_of_work.py | 5 +- .../abstractions/repositories/__init__.py | 3 +- .../repositories/i_user_repository.py | 9 +++ src/application/commands/__init__.py | 3 +- .../create_payment_cloudkassir_command.py | 70 +++++++++++++++++++ src/infrastructure/config/settings.py | 24 +++++++ .../database/repositories/user_repository.py | 50 +++++++++++++ src/infrastructure/database/unit_of_work.py | 25 +++---- src/presentation/dependencies/commands.py | 25 ++++++- src/presentation/routing/order.py | 8 +-- 10 files changed, 197 insertions(+), 25 deletions(-) create mode 100644 src/application/abstractions/repositories/i_user_repository.py create mode 100644 src/application/commands/create_payment_cloudkassir_command.py create mode 100644 src/infrastructure/database/repositories/user_repository.py diff --git a/src/application/abstractions/i_unit_of_work.py b/src/application/abstractions/i_unit_of_work.py index 7db4a2d..6cefe4a 100644 --- a/src/application/abstractions/i_unit_of_work.py +++ b/src/application/abstractions/i_unit_of_work.py @@ -1,6 +1,6 @@ from __future__ import annotations from typing import Protocol, runtime_checkable -from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository +from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository @runtime_checkable @@ -17,3 +17,6 @@ class IUnitOfWork(Protocol): @property def payment_repository(self) -> IPaymentRepository: ... + @property + def user_repository(self) -> IUserRepository: ... + diff --git a/src/application/abstractions/repositories/__init__.py b/src/application/abstractions/repositories/__init__.py index a7a94c4..7a85a0d 100644 --- a/src/application/abstractions/repositories/__init__.py +++ b/src/application/abstractions/repositories/__init__.py @@ -1,2 +1,3 @@ from src.application.abstractions.repositories.i_order_repository import IOrderRepository -from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository \ No newline at end of file +from src.application.abstractions.repositories.i_payment_repository import IPaymentRepository +from src.application.abstractions.repositories.i_user_repository import IUserRepository \ No newline at end of file diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py new file mode 100644 index 0000000..91567a9 --- /dev/null +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -0,0 +1,9 @@ +from abc import ABC,abstractmethod + +from src.application.domain.entities.user import UserEntity + + +class IUserRepository(ABC): + @abstractmethod + async def get(self,user_id:str) -> UserEntity|None: + raise NotImplementedError diff --git a/src/application/commands/__init__.py b/src/application/commands/__init__.py index 9fcf0d9..e5be14e 100644 --- a/src/application/commands/__init__.py +++ b/src/application/commands/__init__.py @@ -1,2 +1,3 @@ from src.application.commands.create_order_command import CreateOrderCommand -from src.application.commands.create_payment_command import CreatePaymentCommand \ No newline at end of file +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 diff --git a/src/application/commands/create_payment_cloudkassir_command.py b/src/application/commands/create_payment_cloudkassir_command.py new file mode 100644 index 0000000..326cc93 --- /dev/null +++ b/src/application/commands/create_payment_cloudkassir_command.py @@ -0,0 +1,70 @@ +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 + + +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') + phone_raw = (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')) + if principal_amount < 0: + raise ApplicationException(status_code=400, message='Invalid receipt amounts: principal negative') + 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, + phone=phone, + request_id=str(ULID()), + ) diff --git a/src/infrastructure/config/settings.py b/src/infrastructure/config/settings.py index fa72202..a816555 100644 --- a/src/infrastructure/config/settings.py +++ b/src/infrastructure/config/settings.py @@ -270,6 +270,30 @@ class Settings(BaseSettings): data['ITPAY_PUBLIC_ID'] = str(public_id).strip() data['ITPAY_API_SECRET'] = str(api_secret).strip() + ck_public = data.get('CLOUD_KASSIR_PUBLIC_ID') or os.getenv('CLOUD_KASSIR_PUBLIC_ID') + ck_secret = data.get('CLOUD_KASSIR_API_SECRET') or os.getenv('CLOUD_KASSIR_API_SECRET') + if ck_public is not None and str(ck_public).strip() and ck_secret is not None and str(ck_secret).strip(): + 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() + return data @property diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py new file mode 100644 index 0000000..5761911 --- /dev/null +++ b/src/infrastructure/database/repositories/user_repository.py @@ -0,0 +1,50 @@ +from __future__ import annotations + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession +from src.application.abstractions.repositories.i_user_repository import IUserRepository +from src.application.contracts import ILogger +from src.application.domain.entities.user import UserEntity +from src.infrastructure.database.models.user import UserModel + + +class UserRepository(IUserRepository): + def __init__(self,session:AsyncSession,logger:ILogger): + self._session=session + self._logger=logger + + + @staticmethod + def _to_entity(model:UserModel) -> UserEntity: + return UserEntity( + id=model.id, + email=model.email, + password_hash=model.password_hash, + first_name=model.first_name, + middle_name=model.middle_name, + last_name=model.last_name, + birth_date=model.birth_date, + crypto_wallet=model.crypto_wallet, + phone=model.phone, + bik=model.bik, + account_number=model.account_number, + card_number=model.card_number, + inn=model.inn, + kyc_verified=model.kyc_verified, + is_deleted=model.is_deleted, + created_at=model.created_at, + updated_at=model.updated_at, + kyc_verified_at=model.kyc_verified_at, + ) + + + async def get(self,user_id:str) -> UserEntity|None: + stmt=( + select(UserModel) + .where(UserModel.id==user_id) + .where(UserModel.is_deleted.is_(False)) + ) + model=await self._session.scalar(stmt) + if model is None: + return None + return self._to_entity(model) diff --git a/src/infrastructure/database/unit_of_work.py b/src/infrastructure/database/unit_of_work.py index b6ddfad..a416698 100644 --- a/src/infrastructure/database/unit_of_work.py +++ b/src/infrastructure/database/unit_of_work.py @@ -1,10 +1,10 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker from src.application.abstractions import IUnitOfWork -from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository +from src.application.abstractions.repositories import IOrderRepository,IPaymentRepository,IUserRepository from src.application.contracts import ILogger from src.infrastructure.database.repositories.order_repository import OrderRepository from src.infrastructure.database.repositories.payment_repository import PaymentRepository -# from src.infrastructure.database.repositories import UserRepository, SessionRepository +from src.infrastructure.database.repositories.user_repository import UserRepository @@ -14,14 +14,14 @@ class UnitOfWork(IUnitOfWork): self._session: AsyncSession = None self._order_repository: IOrderRepository | None = None self._payment_repository: IPaymentRepository | None = None - # self._user_repository: IUserRepository = None - # self._session_repository: ISessionRepository = None + self._user_repository: IUserRepository | None = None self._logger: ILogger = logger async def __aenter__(self): self._session = self.session_factory() self._order_repository = None self._payment_repository = None + self._user_repository = None return self async def __aexit__(self, exc_type, exc_val, exc_tb): @@ -48,14 +48,9 @@ class UnitOfWork(IUnitOfWork): self._payment_repository = PaymentRepository(session=self._session, logger=self._logger) return self._payment_repository - # @property - # def user_repository(self) -> IUserRepository: - # if self._user_repository is None: - # self._user_repository = UserRepository(session=self._session, logger=self._logger) - # return self._user_repository - # - # @property - # def session_repository(self) -> ISessionRepository: - # if self._session_repository is None: - # self._session_repository = SessionRepository(session=self._session, logger=self._logger) - # return self._session_repository + + @property + def user_repository(self) -> IUserRepository: + if self._user_repository is None: + self._user_repository = UserRepository(session=self._session, logger=self._logger) + return self._user_repository diff --git a/src/presentation/dependencies/commands.py b/src/presentation/dependencies/commands.py index c0c2bfe..f635d5d 100644 --- a/src/presentation/dependencies/commands.py +++ b/src/presentation/dependencies/commands.py @@ -1,9 +1,10 @@ from __future__ import annotations from fastapi import Depends from src.application.abstractions import IUnitOfWork -from src.application.commands import CreateOrderCommand,CreatePaymentCommand -from src.application.contracts import ICache,ILogger,IQueueMessanger +from src.application.commands import CreateOrderCommand,CreatePaymentCommand,CreatePaymentCloudkassirCommand +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.config import settings from src.infrastructure.itpay.client import ItPayClient from src.presentation.dependencies.cache import get_remote_cache @@ -38,4 +39,22 @@ def get_create_payment_command( unit_of_work: IUnitOfWork = Depends(get_unit_of_work), queue_messanger: IQueueMessanger = Depends(get_rabbit), ) -> CreatePaymentCommand: - return CreatePaymentCommand(unit_of_work=unit_of_work,logger=logger,queue_messanger=queue_messanger) \ No newline at end of file + return CreatePaymentCommand(unit_of_work=unit_of_work,logger=logger,queue_messanger=queue_messanger) + + +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, + ) + + +def get_create_payment_cloudkassir_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 diff --git a/src/presentation/routing/order.py b/src/presentation/routing/order.py index 6b88653..c5efd21 100644 --- a/src/presentation/routing/order.py +++ b/src/presentation/routing/order.py @@ -3,12 +3,12 @@ import orjson from fastapi import APIRouter, Depends, Request from fastapi.responses import ORJSONResponse from src.application.commands import CreateOrderCommand -from src.application.commands import CreatePaymentCommand +from src.application.commands import CreatePaymentCloudkassirCommand from src.application.contracts import ILogger from src.application.domain.dto import AuthContext from src.application.domain.enums import OrderStatus from src.presentation.decorators import require_access_token, csrf_protect -from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_command +from src.presentation.dependencies.commands import get_create_order_command, get_create_payment_cloudkassir_command from src.presentation.dependencies.logger import get_logger from src.presentation.schemas.order import CreateOrder from src.presentation.schemas.itpay_payment_models import ItpayPaymentData @@ -73,8 +73,8 @@ async def create_order( @order_router.post('/webhook/itpay') async def itpay_webhook( request: Request, - payment_command: CreatePaymentCommand = Depends(get_create_payment_command), - logger: ILogger = Depends(get_logger) + payment_command: CreatePaymentCloudkassirCommand = Depends(get_create_payment_cloudkassir_command), + logger: ILogger = Depends(get_logger), ) -> ORJSONResponse: raw = await request.body() ct = (request.headers.get('content-type') or '').lower()