Compare commits
26 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2834a7a52 | ||
|
|
a5600c75ae | ||
|
|
f9506be2df | ||
|
|
4c07cb1f29 | ||
| 0bea5bfb5a | |||
| 2b9b54715f | |||
| 4490482f48 | |||
| 1247b79e0b | |||
| 8ebe016591 | |||
| 909f4d9298 | |||
| 4bdf295007 | |||
| 9243d3db2d | |||
| 0346493648 | |||
| a97605c478 | |||
| db30feb0e2 | |||
| 6f8206c867 | |||
| 4aae631c73 | |||
| e0f044b455 | |||
| 99714ac476 | |||
| de870b78d2 | |||
| 5d7cdf67be | |||
| 5e085ae67e | |||
| dc05528405 | |||
| f9dc59729c | |||
| cc75f61e76 | |||
| 14df805209 |
127
docs/frontend-risk-scoring-requirements.md
Normal file
127
docs/frontend-risk-scoring-requirements.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Frontend Requirements: Risk, Audit, Funnel
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Frontend code is not changed in this backend release. The admin frontend should add read-only screens for individual-client risk scoring, security audit events, and the product funnel.
|
||||||
|
|
||||||
|
Legal-entity B2B flows are still manual and must not display automatic risk scoring decisions.
|
||||||
|
|
||||||
|
## User Payment Gate
|
||||||
|
|
||||||
|
Individual users cannot create payment orders until KYC is completed.
|
||||||
|
|
||||||
|
Expected client behavior:
|
||||||
|
|
||||||
|
- If the payment API returns `403` with `KYC verification is required before creating payment orders`, show a blocking state.
|
||||||
|
- Primary action: open or continue KYC.
|
||||||
|
- Do not show the QR/payment step.
|
||||||
|
- Do not retry order creation automatically.
|
||||||
|
|
||||||
|
Suggested copy:
|
||||||
|
|
||||||
|
- Title: `Для оплаты нужно пройти KYC`
|
||||||
|
- Body: `После подтверждения личности платежи станут доступны.`
|
||||||
|
- Button: `Пройти KYC`
|
||||||
|
|
||||||
|
## Admin Navigation
|
||||||
|
|
||||||
|
Add two admin sections:
|
||||||
|
|
||||||
|
- `Risk`
|
||||||
|
- `Audit`
|
||||||
|
|
||||||
|
Add one analytics section:
|
||||||
|
|
||||||
|
- `Funnel`
|
||||||
|
|
||||||
|
## Risk Screen
|
||||||
|
|
||||||
|
Backend endpoints:
|
||||||
|
|
||||||
|
- `GET /v1/risk/summary`
|
||||||
|
- `GET /v1/risk/assessments?limit=50&offset=0&decision=&user_id=&order_id=`
|
||||||
|
|
||||||
|
Summary cards:
|
||||||
|
|
||||||
|
- Total assessments
|
||||||
|
- Allowed
|
||||||
|
- Manual review
|
||||||
|
- Rejected
|
||||||
|
- Average score
|
||||||
|
- High risk
|
||||||
|
|
||||||
|
Assessment table columns:
|
||||||
|
|
||||||
|
- Created at
|
||||||
|
- User ID
|
||||||
|
- Order ID
|
||||||
|
- Score
|
||||||
|
- Decision
|
||||||
|
- Reasons
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- Decision: `allow`, `manual_review`, `reject`
|
||||||
|
- User ID
|
||||||
|
- Order ID
|
||||||
|
|
||||||
|
Decision badges:
|
||||||
|
|
||||||
|
- `allow`: green
|
||||||
|
- `manual_review`: yellow
|
||||||
|
- `reject`: red
|
||||||
|
|
||||||
|
## Audit Screen
|
||||||
|
|
||||||
|
Backend endpoint:
|
||||||
|
|
||||||
|
- `GET /v1/audit/events?limit=50&offset=0&severity=&action=&entity_type=`
|
||||||
|
|
||||||
|
Table columns:
|
||||||
|
|
||||||
|
- Created at
|
||||||
|
- Severity
|
||||||
|
- Action
|
||||||
|
- Actor type
|
||||||
|
- Actor ID
|
||||||
|
- Entity type
|
||||||
|
- Entity ID
|
||||||
|
- Metadata
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- Severity
|
||||||
|
- Action
|
||||||
|
- Entity type
|
||||||
|
|
||||||
|
Use collapsible JSON rendering for `metadata`.
|
||||||
|
|
||||||
|
## Funnel Screen
|
||||||
|
|
||||||
|
Backend endpoint:
|
||||||
|
|
||||||
|
- `GET /v1/analytics/funnel`
|
||||||
|
|
||||||
|
Display the funnel as ordered steps:
|
||||||
|
|
||||||
|
1. Registrations
|
||||||
|
2. KYC started
|
||||||
|
3. KYC completed
|
||||||
|
4. First payment
|
||||||
|
5. Successful operations
|
||||||
|
|
||||||
|
For each step after the first, show conversion from the previous step and conversion from registrations.
|
||||||
|
|
||||||
|
## Grafana Links
|
||||||
|
|
||||||
|
Expose optional links from the admin UI:
|
||||||
|
|
||||||
|
- `https://corp.grafana.elcsa.ru/d/elcsa-audit-security/infrastructure-audit-and-security`
|
||||||
|
- `https://corp.grafana.elcsa.ru/d/elcsa-product-funnel/product-funnel`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not show raw internal risk rule weights to end users.
|
||||||
|
- User-facing apps should show human states such as `Нужна проверка`, not numeric scores.
|
||||||
|
- Admin screens may show score, decision, and reasons.
|
||||||
|
- Legal entities should not use automatic risk scoring until KYB automation is added.
|
||||||
@@ -4,14 +4,14 @@ version = "0.1.0"
|
|||||||
description = "Admin service for legal entities and B2B operations"
|
description = "Admin service for legal entities and B2B operations"
|
||||||
requires-python = "==3.12.*"
|
requires-python = "==3.12.*"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"acryl-datahub>=1.5.0.19",
|
"acryl-datahub==1.5.0.19",
|
||||||
"acryl-sqlglot>=25.25.2.dev9",
|
"acryl-sqlglot==25.25.2.dev9",
|
||||||
"aiobotocore>=2.21.0",
|
"aiobotocore==2.21.0",
|
||||||
"apscheduler==3.11.2",
|
"apscheduler==3.11.2",
|
||||||
"asyncpg==0.31.0",
|
"asyncpg==0.31.0",
|
||||||
"bcrypt==5.0.0",
|
"bcrypt==5.0.0",
|
||||||
"bip-utils>=2.9.3",
|
"bip-utils==2.9.3",
|
||||||
"cryptography>=44.0.0",
|
"cryptography==44.0.0",
|
||||||
"dotenv==0.9.9",
|
"dotenv==0.9.9",
|
||||||
"fastapi==0.128.7",
|
"fastapi==0.128.7",
|
||||||
"granian==2.6.1",
|
"granian==2.6.1",
|
||||||
@@ -19,7 +19,7 @@ dependencies = [
|
|||||||
"orjson==3.11.7",
|
"orjson==3.11.7",
|
||||||
"pydantic-settings==2.12.0",
|
"pydantic-settings==2.12.0",
|
||||||
"python-jose==3.5.0",
|
"python-jose==3.5.0",
|
||||||
"python-multipart>=0.0.20",
|
"python-multipart==0.0.20",
|
||||||
"python-ulid==3.1.0",
|
"python-ulid==3.1.0",
|
||||||
"redis==7.2.0",
|
"redis==7.2.0",
|
||||||
"sqlalchemy==2.0.46",
|
"sqlalchemy==2.0.46",
|
||||||
|
|||||||
@@ -6,10 +6,12 @@ from src.application.abstractions.repositories import (
|
|||||||
IAdminSessionRepository,
|
IAdminSessionRepository,
|
||||||
IAdminUserRepository,
|
IAdminUserRepository,
|
||||||
ILegalEntityRepository,
|
ILegalEntityRepository,
|
||||||
IOrganizationDocumentRepository,
|
|
||||||
IOrganizationWalletRepository,
|
IOrganizationWalletRepository,
|
||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
|
IWalletRepository,
|
||||||
|
IOrderRepository,
|
||||||
|
IRiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -37,7 +39,13 @@ class IUnitOfWork(Protocol):
|
|||||||
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
|
def organization_wallet_repository(self) -> IOrganizationWalletRepository: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def organization_document_repository(self) -> IOrganizationDocumentRepository: ...
|
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
|
def wallet_repository(self) -> IWalletRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_repository(self) -> IOrderRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository: ...
|
||||||
@@ -3,5 +3,7 @@ from src.application.abstractions.repositories.i_admin_user_repository import IA
|
|||||||
from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository
|
from src.application.abstractions.repositories.i_admin_session_repository import IAdminSessionRepository
|
||||||
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
|
from src.application.abstractions.repositories.i_legal_entity_repository import ILegalEntityRepository
|
||||||
from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository
|
from src.application.abstractions.repositories.i_organization_wallet_repository import IOrganizationWalletRepository
|
||||||
from src.application.abstractions.repositories.i_organization_document_repository import IOrganizationDocumentRepository
|
|
||||||
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
from src.application.abstractions.repositories.i_purchase_request_repository import IPurchaseRequestRepository
|
||||||
|
from src.application.abstractions.repositories.i_wallets_repository import IWalletRepository
|
||||||
|
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ class ILegalEntityRepository(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
|
async def list_all(self, *, limit: int, offset: int, search: str | None = None) -> list[LegalEntityEntity]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
@@ -49,5 +49,13 @@ class ILegalEntityRepository(ABC):
|
|||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_all(self) -> int:
|
async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_all(self, *, search: str | None = None) -> int:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -0,0 +1,50 @@
|
|||||||
|
from abc import ABC,abstractmethod
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from src.application.domain.entities.order import OrderEntity
|
||||||
|
|
||||||
|
|
||||||
|
class IOrderRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self,order_id: str) -> OrderEntity | None:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_user_id(self,*,user_id: str,limit: int,offset: int) -> list[OrderEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_all(self,*,limit: int,offset: int, search: str | None = None) -> list[OrderEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_date(self,*,year: int,month: int,limit: int,offset: int) -> list[OrderEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def sum_by_month(self,*,year: int,month: int, search: str | None = None) -> Decimal:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def sum_by_day(self,*,year: int,month: int,day: int, search: str | None = None) -> Decimal:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_all(self,*,search: str | None = None) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_by_month(self,*,year: int,month: int | None = None, search: str | None = None) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_by_day(self,*,year: int,month: int,day: int, search: str | None = None) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from src.application.domain.entities.organization import OrganizationDocumentEntity
|
|
||||||
|
|
||||||
|
|
||||||
class IOrganizationDocumentRepository(ABC):
|
|
||||||
@abstractmethod
|
|
||||||
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
|
|
||||||
raise NotImplementedError
|
|
||||||
@@ -5,6 +5,10 @@ from src.application.domain.entities.organization import PurchaseRequestEntity
|
|||||||
|
|
||||||
|
|
||||||
class IPurchaseRequestRepository(ABC):
|
class IPurchaseRequestRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def create(self, values: dict[str, Any]) -> PurchaseRequestEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
|
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
@@ -27,3 +31,26 @@ class IPurchaseRequestRepository(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_all(self, *, status: str | None, organization_id: str | None) -> int:
|
async def count_all(self, *, status: str | None, organization_id: str | None) -> int:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_for_month(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
status: str | None,
|
||||||
|
organization_id: str | None
|
||||||
|
) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_for_day(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
day: int,
|
||||||
|
status: str | None,
|
||||||
|
organization_id: str | None
|
||||||
|
) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
|
||||||
|
|
||||||
|
class IRiskRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from src.application.domain.entities import UserEntity
|
from src.application.domain.entities import UserEntity
|
||||||
from src.application.domain.entities.organization import PartySearchEntity
|
from src.application.domain.entities.party_search import PartySearchEntity
|
||||||
|
|
||||||
|
|
||||||
class IUserRepository(ABC):
|
class IUserRepository(ABC):
|
||||||
@@ -18,6 +18,14 @@ class IUserRepository(ABC):
|
|||||||
kyc_verified_at: datetime,
|
kyc_verified_at: datetime,
|
||||||
) -> UserEntity:
|
) -> UserEntity:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_all(self, *, limit: int, offset: int, search: str | None = None) -> list[UserEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def get_by_id(self, user_id: str) -> UserEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def get_user_by_email(self, email: str) -> UserEntity:
|
async def get_user_by_email(self, email: str) -> UserEntity:
|
||||||
@@ -26,19 +34,19 @@ class IUserRepository(ABC):
|
|||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def exists_by_email(self, email: str) -> bool:
|
async def exists_by_email(self, email: str) -> bool:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def search_individuals(self, *, query: str, limit: int, offset: int) -> list[PartySearchEntity]:
|
async def search_individuals(self, *, query: str, limit: int, offset: int) -> list[PartySearchEntity]:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def count_individuals(self, *, query: str) -> int:
|
async def count_individuals(self, *, query: str) -> int:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
|
||||||
async def get_password_hash(self, user_id: str) -> str:
|
|
||||||
raise NotImplementedError
|
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def count_all(self, *, search: str) -> int:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
|
async def set_password(self, user_id: str, password_hash: str) -> UserEntity:
|
||||||
raise NotImplementedError
|
raise NotImplementedError
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from src.application.domain.entities.user import WalletEntity
|
||||||
|
|
||||||
|
|
||||||
|
class IWalletRepository(ABC):
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_by_user(self, user_id: str) -> list[WalletEntity]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def exists_for_user(self, user_id: str) -> bool:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -1,12 +1,15 @@
|
|||||||
|
from src.application.commands.admin_login import AdminLoginCommand
|
||||||
|
from src.application.commands.admin_logout import AdminLogoutCommand
|
||||||
from src.application.commands.admin_jwt_refresh import AdminJwtRefreshCommand
|
from src.application.commands.admin_jwt_refresh import AdminJwtRefreshCommand
|
||||||
from src.application.commands.get_admin_me import GetAdminMeCommand
|
from src.application.commands.get_admin_me import GetAdminMeCommand
|
||||||
from src.application.commands.create_organization import CreateOrganizationCommand
|
from src.application.commands.create_organization import CreateOrganizationCommand
|
||||||
from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand
|
from src.application.commands.create_organization_wallets import CreateOrganizationWalletsCommand
|
||||||
from src.application.commands.put_organization_document import PutOrganizationDocumentCommand
|
from src.application.commands.put_organization_document import PutOrganizationDocumentCommand
|
||||||
|
from src.application.commands.set_password import SetPasswordCommand
|
||||||
|
from src.application.commands.party_search_command import SearchPartiesCommand
|
||||||
from src.application.commands.organization_commands import (
|
from src.application.commands.organization_commands import (
|
||||||
ListOrganizationsCommand,
|
ListOrganizationsCommand,
|
||||||
GetOrganizationCommand,
|
GetOrganizationCommand,
|
||||||
SearchPartiesCommand,
|
|
||||||
UpdateOrganizationCommand,
|
UpdateOrganizationCommand,
|
||||||
)
|
)
|
||||||
from src.application.commands.organization_document_commands import (
|
from src.application.commands.organization_document_commands import (
|
||||||
@@ -19,12 +22,37 @@ from src.application.commands.organization_wallet_commands import (
|
|||||||
ListOrganizationWalletsCommand,
|
ListOrganizationWalletsCommand,
|
||||||
)
|
)
|
||||||
from src.application.commands.purchase_request_commands import (
|
from src.application.commands.purchase_request_commands import (
|
||||||
|
CreatePurchaseRequestCommand,
|
||||||
ListPurchaseRequestsCommand,
|
ListPurchaseRequestsCommand,
|
||||||
GetPurchaseRequestCommand,
|
GetPurchaseRequestCommand,
|
||||||
UpdatePurchaseRequestCommand,
|
UpdatePurchaseRequestCommand,
|
||||||
UpdatePurchaseRequestStatusCommand,
|
UpdatePurchaseRequestStatusCommand,
|
||||||
SetPurchaseRequestQuoteCommand,
|
SetPurchaseRequestQuoteCommand,
|
||||||
)
|
)
|
||||||
|
from src.application.commands.user_commands import (
|
||||||
|
ListUsersCommand,
|
||||||
|
GetUserCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.user_wallet_commands import (
|
||||||
|
GetUserMnemonicCommand,
|
||||||
|
GetUserSecretKeysCommand,
|
||||||
|
ListUserWalletsCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.orders_commands import (
|
||||||
|
ListOrdersCommand,
|
||||||
|
ListUserOrdersCommand,
|
||||||
|
ListOrdersByDateCommand,
|
||||||
|
GetOrderCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.risk_commands import GetFunnelCommand,GetRiskSummaryCommand,ListAuditEventsCommand,ListRiskAssessmentsCommand
|
||||||
|
from src.application.commands.analytics_commands import (
|
||||||
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
|
GetOrdersAnalyticsForYearCommand,
|
||||||
|
GetPurchaseAnalyticsForMonthCommand,
|
||||||
|
GetOrdersAnalyticsForMonthCommand,
|
||||||
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AdminLoginCommand',
|
'AdminLoginCommand',
|
||||||
@@ -38,6 +66,7 @@ __all__ = [
|
|||||||
'GetOrganizationCommand',
|
'GetOrganizationCommand',
|
||||||
'SearchPartiesCommand',
|
'SearchPartiesCommand',
|
||||||
'UpdateOrganizationCommand',
|
'UpdateOrganizationCommand',
|
||||||
|
'CreatePurchaseRequestCommand',
|
||||||
'ListPurchaseRequestsCommand',
|
'ListPurchaseRequestsCommand',
|
||||||
'GetPurchaseRequestCommand',
|
'GetPurchaseRequestCommand',
|
||||||
'UpdatePurchaseRequestCommand',
|
'UpdatePurchaseRequestCommand',
|
||||||
@@ -48,4 +77,24 @@ __all__ = [
|
|||||||
'ListOrganizationWalletsCommand',
|
'ListOrganizationWalletsCommand',
|
||||||
'GetOrganizationMnemonicCommand',
|
'GetOrganizationMnemonicCommand',
|
||||||
'GetOrganizationSecretKeysCommand',
|
'GetOrganizationSecretKeysCommand',
|
||||||
]
|
'SetPasswordCommand',
|
||||||
|
'ListUsersCommand',
|
||||||
|
'GetUserCommand',
|
||||||
|
'ListUserWalletsCommand',
|
||||||
|
'GetUserMnemonicCommand',
|
||||||
|
'GetUserSecretKeysCommand',
|
||||||
|
'ListOrdersCommand',
|
||||||
|
'ListUserOrdersCommand',
|
||||||
|
'ListOrdersByDateCommand',
|
||||||
|
'GetOrderCommand',
|
||||||
|
'GetPurchaseAnalyticsForYearCommand',
|
||||||
|
'GetOrdersAnalyticsForYearCommand',
|
||||||
|
'GetPurchaseAnalyticsForMonthCommand',
|
||||||
|
'GetOrdersAnalyticsForMonthCommand',
|
||||||
|
'GetPurchaseAnalyticsForWeekCommand',
|
||||||
|
'GetOrdersAnalyticsForWeekCommand',
|
||||||
|
'ListRiskAssessmentsCommand',
|
||||||
|
'GetRiskSummaryCommand',
|
||||||
|
'ListAuditEventsCommand',
|
||||||
|
'GetFunnelCommand',
|
||||||
|
]
|
||||||
|
|||||||
239
src/application/commands/analytics_commands.py
Normal file
239
src/application/commands/analytics_commands.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities import OrderEntity, PurchaseRequestEntity
|
||||||
|
from src.application.domain.exceptions import NotFoundException
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
YEAR=12
|
||||||
|
MONTH=30
|
||||||
|
WEEK=7
|
||||||
|
|
||||||
|
def _count_date(
|
||||||
|
year: int,
|
||||||
|
month: int | None = None,
|
||||||
|
day: int | None = None,
|
||||||
|
) -> tuple[int, int | None, int | None]:
|
||||||
|
if month is None:
|
||||||
|
return year - 1, None, None
|
||||||
|
|
||||||
|
if day is None:
|
||||||
|
return (year - 1, 12, None) if month == 1 else (year, month - 1, None)
|
||||||
|
|
||||||
|
prev_date = date(year, month, day) - timedelta(days=1)
|
||||||
|
return prev_date.year, prev_date.month, prev_date.day
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForYearCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[int], list[Decimal], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month = start_year, start_month
|
||||||
|
|
||||||
|
for _ in range(YEAR):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, _ = _count_date(year, month)
|
||||||
|
return orders, sums, sum(orders), sum(sums), YEAR
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForYearCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[int], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month = start_year, start_month
|
||||||
|
|
||||||
|
for _ in range(YEAR):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, _ = _count_date(year, month)
|
||||||
|
return requests, sum(requests), YEAR
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForMonthCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[int], list[Decimal], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(MONTH):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return orders, sums, sum(orders), sum(sums), MONTH
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForMonthCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[PurchaseRequestEntity], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(MONTH):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return requests, sum(requests), MONTH
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForWeekCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[PurchaseRequestEntity], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(WEEK):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return requests, sum(requests), WEEK
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForWeekCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[OrderEntity], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(WEEK):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return orders, sums, sum(orders), sum(sums), WEEK
|
||||||
@@ -4,7 +4,10 @@ from ulid import ULID
|
|||||||
|
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities.organization import OrganizationWalletEntity
|
from src.application.domain.entities.organization import (
|
||||||
|
CreateOrganizationWalletsResult,
|
||||||
|
OrganizationWalletEntity,
|
||||||
|
)
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
from src.infrastructure.crypto.wallet_crypto import (
|
from src.infrastructure.crypto.wallet_crypto import (
|
||||||
ALL_CHAINS,
|
ALL_CHAINS,
|
||||||
@@ -22,7 +25,7 @@ class CreateOrganizationWalletsCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, *, organization_id: str) -> list[OrganizationWalletEntity]:
|
async def __call__(self, *, organization_id: str) -> CreateOrganizationWalletsResult:
|
||||||
if not is_crypto_ready():
|
if not is_crypto_ready():
|
||||||
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
||||||
|
|
||||||
@@ -35,6 +38,7 @@ class CreateOrganizationWalletsCommand:
|
|||||||
|
|
||||||
mnemonic = generate_mnemonic()
|
mnemonic = generate_mnemonic()
|
||||||
derived = derive_all_addresses(mnemonic)
|
derived = derive_all_addresses(mnemonic)
|
||||||
|
plaintext_mnemonic = mnemonic
|
||||||
blob = encrypt_mnemonic(mnemonic)
|
blob = encrypt_mnemonic(mnemonic)
|
||||||
mnemonic = ''
|
mnemonic = ''
|
||||||
|
|
||||||
@@ -53,4 +57,4 @@ class CreateOrganizationWalletsCommand:
|
|||||||
]
|
]
|
||||||
saved = await self._unit_of_work.organization_wallet_repository.create_many(wallets)
|
saved = await self._unit_of_work.organization_wallet_repository.create_many(wallets)
|
||||||
self._logger.info(f'Wallets created for organization_id={organization_id} chains={len(saved)}')
|
self._logger.info(f'Wallets created for organization_id={organization_id} chains={len(saved)}')
|
||||||
return saved
|
return CreateOrganizationWalletsResult(wallets=saved, mnemonic=plaintext_mnemonic)
|
||||||
|
|||||||
101
src/application/commands/orders_commands.py
Normal file
101
src/application/commands/orders_commands.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities import OrderEntity
|
||||||
|
from src.application.domain.exceptions import NotFoundException
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(cursor: str | None) -> datetime | None:
|
||||||
|
if not cursor:
|
||||||
|
return None
|
||||||
|
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class ListUserOrdersCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, user_id: str, limit: int, offset: int, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_by_user_id(
|
||||||
|
user_id=user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
total = await self._unit_of_work.order_repository.count_all(search=user_id)
|
||||||
|
return orders, total
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ListOrdersCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, search: str | None = None, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_all(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
search=search,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await self._unit_of_work.order_repository.count_all(search=search)
|
||||||
|
|
||||||
|
return orders, total
|
||||||
|
|
||||||
|
|
||||||
|
class ListOrdersByDateCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
year: int,
|
||||||
|
month: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> tuple[list[OrderEntity], int, Decimal]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_by_date(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await self._unit_of_work.order_repository.count_by_month(year=year, month=month)
|
||||||
|
summary = await self._unit_of_work.order_repository.sum_by_month(year=year, month=month)
|
||||||
|
|
||||||
|
return orders, total, summary
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrderCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, order_id: str) -> OrderEntity:
|
||||||
|
order = await self._unit_of_work.order_repository.get_by_id(order_id=order_id)
|
||||||
|
if order is None:
|
||||||
|
raise NotFoundException(message='Order not found')
|
||||||
|
|
||||||
|
return order
|
||||||
@@ -14,9 +14,19 @@ class ListOrganizationsCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, *, limit: int = 50, offset: int = 0) -> tuple[list[LegalEntityEntity], int]:
|
async def __call__(
|
||||||
items = await self._unit_of_work.legal_entity_repository.list_all(limit=limit, offset=offset)
|
self,
|
||||||
total = await self._unit_of_work.legal_entity_repository.count_all()
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[LegalEntityEntity], int]:
|
||||||
|
items = await self._unit_of_work.legal_entity_repository.list_all(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
total = await self._unit_of_work.legal_entity_repository.count_all(search=search)
|
||||||
return items, total
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,11 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities.organization import OrganizationDocumentEntity
|
from src.application.domain.entities.organization import OrganizationDocumentSlot
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
|
from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES
|
||||||
from src.infrastructure.database.decorators import transactional
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
||||||
|
|
||||||
|
|
||||||
class ListOrganizationDocumentsCommand:
|
class ListOrganizationDocumentsCommand:
|
||||||
@@ -13,9 +15,20 @@ class ListOrganizationDocumentsCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, organization_id: str) -> list[OrganizationDocumentEntity]:
|
async def __call__(self, organization_id: str) -> list[OrganizationDocumentSlot]:
|
||||||
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
return await self._unit_of_work.organization_document_repository.list_by_organization(organization_id)
|
slots: list[OrganizationDocumentSlot] = []
|
||||||
|
for document_type in ORGANIZATION_DOCUMENT_TYPES:
|
||||||
|
s3_key = org.document_s3_keys.get(document_type)
|
||||||
|
slots.append(
|
||||||
|
OrganizationDocumentSlot(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type=document_type,
|
||||||
|
s3_key=s3_key,
|
||||||
|
file_name=S3DocumentsService.file_name_from_key(s3_key) if s3_key else None,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return slots
|
||||||
|
|
||||||
|
|
||||||
class GetOrganizationDocumentCommand:
|
class GetOrganizationDocumentCommand:
|
||||||
@@ -24,8 +37,18 @@ class GetOrganizationDocumentCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, organization_id: str, document_id: str) -> OrganizationDocumentEntity:
|
async def __call__(self, organization_id: str, document_type: str) -> OrganizationDocumentSlot:
|
||||||
doc = await self._unit_of_work.organization_document_repository.get_by_id(document_id)
|
if document_type not in ORGANIZATION_DOCUMENT_TYPES:
|
||||||
if doc.organization_id != organization_id:
|
raise ApplicationException(status_code=400, message='Invalid document type')
|
||||||
raise ApplicationException(status_code=404, message='Document not found')
|
|
||||||
return doc
|
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
s3_key = org.document_s3_keys.get(document_type)
|
||||||
|
if not s3_key:
|
||||||
|
raise ApplicationException(status_code=404, message='Document not uploaded')
|
||||||
|
|
||||||
|
return OrganizationDocumentSlot(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type=document_type,
|
||||||
|
s3_key=s3_key,
|
||||||
|
file_name=S3DocumentsService.file_name_from_key(s3_key),
|
||||||
|
)
|
||||||
|
|||||||
63
src/application/commands/organization_wallet_commands.py
Normal file
63
src/application/commands/organization_wallet_commands.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.organization import LegalEntityEntity, OrganizationWalletEntity
|
||||||
|
from src.application.domain.exceptions import ApplicationException
|
||||||
|
from src.infrastructure.crypto.wallet_crypto import (
|
||||||
|
DerivedSecretKey,
|
||||||
|
decrypt_mnemonic,
|
||||||
|
derive_all_private_keys,
|
||||||
|
is_crypto_ready,
|
||||||
|
)
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class ListOrganizationWalletsCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, organization_id: str) -> list[OrganizationWalletEntity]:
|
||||||
|
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
return await self._unit_of_work.organization_wallet_repository.list_by_organization(organization_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrganizationMnemonicCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, organization_id: str) -> str:
|
||||||
|
org = await self._require_wallets_org(organization_id)
|
||||||
|
if not is_crypto_ready():
|
||||||
|
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
||||||
|
return decrypt_mnemonic(org.encrypted_mnemonic or '')
|
||||||
|
|
||||||
|
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
|
||||||
|
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
if not org.encrypted_mnemonic:
|
||||||
|
raise ApplicationException(status_code=404, message='Wallets not created for organization')
|
||||||
|
return org
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrganizationSecretKeysCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, organization_id: str) -> list[DerivedSecretKey]:
|
||||||
|
org = await self._require_wallets_org(organization_id)
|
||||||
|
if not is_crypto_ready():
|
||||||
|
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
||||||
|
mnemonic = decrypt_mnemonic(org.encrypted_mnemonic or '')
|
||||||
|
return derive_all_private_keys(mnemonic)
|
||||||
|
|
||||||
|
async def _require_wallets_org(self, organization_id: str) -> LegalEntityEntity:
|
||||||
|
org = await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
if not org.encrypted_mnemonic:
|
||||||
|
raise ApplicationException(status_code=404, message='Wallets not created for organization')
|
||||||
|
return org
|
||||||
51
src/application/commands/party_search_command.py
Normal file
51
src/application/commands/party_search_command.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.organization import LegalEntityEntity
|
||||||
|
from src.application.domain.entities.party_search import PartySearchEntity
|
||||||
|
from src.application.domain.enums.account_type import AccountType
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class SearchPartiesCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, query: str, limit: int = 50, offset: int = 0) -> tuple[list[PartySearchEntity], int]:
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
fetch_limit = limit + offset
|
||||||
|
legal_items = await self._unit_of_work.legal_entity_repository.list_all(
|
||||||
|
limit=fetch_limit,
|
||||||
|
offset=0,
|
||||||
|
search=query,
|
||||||
|
)
|
||||||
|
individual_items = await self._unit_of_work.user_repository.search_individuals(
|
||||||
|
query=query,
|
||||||
|
limit=fetch_limit,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
legal_total = await self._unit_of_work.legal_entity_repository.count_all(search=query)
|
||||||
|
individual_total = await self._unit_of_work.user_repository.count_individuals(query=query)
|
||||||
|
items = [self._legal_to_search_entity(item) for item in legal_items] + individual_items
|
||||||
|
items.sort(key=lambda item: item.created_at.timestamp() if item.created_at else 0, reverse=True)
|
||||||
|
return items[offset:offset + limit], legal_total + individual_total
|
||||||
|
|
||||||
|
def _legal_to_search_entity(self, item: LegalEntityEntity) -> PartySearchEntity:
|
||||||
|
return PartySearchEntity(
|
||||||
|
id=item.id,
|
||||||
|
account_type=AccountType.LEGAL_ENTITY,
|
||||||
|
user_id=item.user_id,
|
||||||
|
email=None,
|
||||||
|
name=item.name,
|
||||||
|
inn=item.inn,
|
||||||
|
phone=item.contact_phone,
|
||||||
|
status=item.status,
|
||||||
|
kyc_verified=item.kyc_verified,
|
||||||
|
created_at=item.created_at,
|
||||||
|
)
|
||||||
@@ -54,6 +54,54 @@ class GetPurchaseRequestCommand:
|
|||||||
return await self._unit_of_work.purchase_request_repository.get_by_id(request_id)
|
return await self._unit_of_work.purchase_request_repository.get_by_id(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePurchaseRequestCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
rub_amount: Decimal,
|
||||||
|
status: str = 'submitted',
|
||||||
|
usdt_amount: Decimal | None = None,
|
||||||
|
exchange_rate: Decimal | None = None,
|
||||||
|
service_fee_percent: Decimal | None = None,
|
||||||
|
comment: str | None = None,
|
||||||
|
admin_comment: str | None = None,
|
||||||
|
target_wallet_chain: str | None = 'ETH',
|
||||||
|
target_wallet_address: str | None = None,
|
||||||
|
tx_hash: str | None = None,
|
||||||
|
assigned_to: str | None = None,
|
||||||
|
) -> PurchaseRequestEntity:
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
raise ApplicationException(status_code=400, message='Invalid status')
|
||||||
|
|
||||||
|
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
values: dict[str, Any] = {
|
||||||
|
'organization_id': organization_id,
|
||||||
|
'status': status,
|
||||||
|
'rub_amount': rub_amount,
|
||||||
|
'usdt_amount': usdt_amount,
|
||||||
|
'exchange_rate': exchange_rate,
|
||||||
|
'service_fee_percent': service_fee_percent,
|
||||||
|
'comment': comment,
|
||||||
|
'admin_comment': admin_comment,
|
||||||
|
'target_wallet_chain': target_wallet_chain,
|
||||||
|
'target_wallet_address': target_wallet_address,
|
||||||
|
'tx_hash': tx_hash,
|
||||||
|
'assigned_to': assigned_to,
|
||||||
|
}
|
||||||
|
if status == 'completed':
|
||||||
|
values['completed_at'] = datetime.now(timezone.utc)
|
||||||
|
|
||||||
|
created = await self._unit_of_work.purchase_request_repository.create(values)
|
||||||
|
self._logger.info(f'Purchase request created id={created.id} org={organization_id}')
|
||||||
|
return created
|
||||||
|
|
||||||
|
|
||||||
class UpdatePurchaseRequestStatusCommand:
|
class UpdatePurchaseRequestStatusCommand:
|
||||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
self._unit_of_work = unit_of_work
|
self._unit_of_work = unit_of_work
|
||||||
@@ -81,10 +129,46 @@ class UpdatePurchaseRequestStatusCommand:
|
|||||||
values['tx_hash'] = tx_hash
|
values['tx_hash'] = tx_hash
|
||||||
if status == 'completed':
|
if status == 'completed':
|
||||||
values['completed_at'] = datetime.now(timezone.utc)
|
values['completed_at'] = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
values['completed_at'] = None
|
||||||
|
|
||||||
return await self._unit_of_work.purchase_request_repository.update(request_id, values=values)
|
return await self._unit_of_work.purchase_request_repository.update(request_id, values=values)
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePurchaseRequestCommand:
|
||||||
|
ALLOWED_FIELDS = frozenset({
|
||||||
|
'status',
|
||||||
|
'usdt_amount',
|
||||||
|
'rub_amount',
|
||||||
|
'exchange_rate',
|
||||||
|
'service_fee_percent',
|
||||||
|
'comment',
|
||||||
|
'admin_comment',
|
||||||
|
'target_wallet_chain',
|
||||||
|
'target_wallet_address',
|
||||||
|
'tx_hash',
|
||||||
|
'assigned_to',
|
||||||
|
})
|
||||||
|
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, request_id: str, *, values: dict[str, Any]) -> PurchaseRequestEntity:
|
||||||
|
filtered = {k: v for k, v in values.items() if k in self.ALLOWED_FIELDS}
|
||||||
|
status = filtered.get('status')
|
||||||
|
if status is not None:
|
||||||
|
if status not in VALID_STATUSES:
|
||||||
|
raise ApplicationException(status_code=400, message='Invalid status')
|
||||||
|
if status == 'completed':
|
||||||
|
filtered['completed_at'] = datetime.now(timezone.utc)
|
||||||
|
else:
|
||||||
|
filtered['completed_at'] = None
|
||||||
|
|
||||||
|
return await self._unit_of_work.purchase_request_repository.update(request_id, values=filtered)
|
||||||
|
|
||||||
|
|
||||||
class SetPurchaseRequestQuoteCommand:
|
class SetPurchaseRequestQuoteCommand:
|
||||||
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
self._unit_of_work = unit_of_work
|
self._unit_of_work = unit_of_work
|
||||||
|
|||||||
73
src/application/commands/put_organization_document.py
Normal file
73
src/application/commands/put_organization_document.py
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.organization import OrganizationDocumentSlot
|
||||||
|
from src.application.domain.exceptions import ApplicationException
|
||||||
|
from src.application.domain.organization_documents import ORGANIZATION_DOCUMENT_TYPES
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
||||||
|
|
||||||
|
|
||||||
|
class PutOrganizationDocumentCommand:
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
unit_of_work: IUnitOfWork,
|
||||||
|
s3_service: S3DocumentsService,
|
||||||
|
logger: ILogger,
|
||||||
|
):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._s3 = s3_service
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
organization_id: str,
|
||||||
|
document_type: str,
|
||||||
|
file_name: str,
|
||||||
|
content_type: str,
|
||||||
|
body: bytes,
|
||||||
|
) -> OrganizationDocumentSlot:
|
||||||
|
if document_type not in ORGANIZATION_DOCUMENT_TYPES:
|
||||||
|
raise ApplicationException(status_code=400, message='Invalid document type')
|
||||||
|
|
||||||
|
if not body:
|
||||||
|
raise ApplicationException(status_code=400, message='File is empty')
|
||||||
|
|
||||||
|
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
||||||
|
old_s3_key = await self._unit_of_work.legal_entity_repository.get_document_s3_key(
|
||||||
|
organization_id,
|
||||||
|
document_type,
|
||||||
|
)
|
||||||
|
|
||||||
|
new_s3_key = self._s3.build_object_key(organization_id, document_type, file_name)
|
||||||
|
await self._s3.upload_bytes(key=new_s3_key, body=body, content_type=content_type)
|
||||||
|
|
||||||
|
try:
|
||||||
|
await self._unit_of_work.legal_entity_repository.set_document_s3_key(
|
||||||
|
organization_id,
|
||||||
|
document_type,
|
||||||
|
new_s3_key,
|
||||||
|
)
|
||||||
|
await self._unit_of_work.commit()
|
||||||
|
except Exception:
|
||||||
|
await self._unit_of_work.rollback()
|
||||||
|
await self._s3.delete_object(key=new_s3_key)
|
||||||
|
raise
|
||||||
|
|
||||||
|
if old_s3_key and old_s3_key != new_s3_key:
|
||||||
|
await self._s3.delete_object(key=old_s3_key)
|
||||||
|
|
||||||
|
self._logger.info(
|
||||||
|
f'Document uploaded org={organization_id} type={document_type} key={new_s3_key}'
|
||||||
|
)
|
||||||
|
return OrganizationDocumentSlot(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type=document_type,
|
||||||
|
s3_key=new_s3_key,
|
||||||
|
file_name=self._s3.file_name_from_key(new_s3_key),
|
||||||
|
content_type=content_type,
|
||||||
|
file_size_bytes=len(body),
|
||||||
|
)
|
||||||
41
src/application/commands/risk_commands.py
Normal file
41
src/application/commands/risk_commands.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class ListRiskAssessmentsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_assessments(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GetRiskSummaryCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> RiskSummaryEntity:
|
||||||
|
return await self._uow.risk_repository.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ListAuditEventsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_audit_events(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
|
||||||
|
|
||||||
|
class GetFunnelCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> FunnelEntity:
|
||||||
|
return await self._uow.risk_repository.funnel()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import IHashService, ILogger
|
from src.application.contracts import IHashService, ILogger
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException, BadRequestException
|
||||||
from src.infrastructure.database.decorators import transactional
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
@@ -16,16 +16,26 @@ class SetPasswordCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, email: str, password: str) -> bool:
|
async def __call__(
|
||||||
|
self,
|
||||||
|
password: str,
|
||||||
|
email: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
user = await self._unit_of_work.user_repository.get_user_by_email(email)
|
password_hash = await self._hash_service.hash(password)
|
||||||
|
|
||||||
password_hash = self._hash_service.hash_password(password)
|
if email == None and user_id == None:
|
||||||
|
raise BadRequestException('An email is required')
|
||||||
|
|
||||||
|
if email != None:
|
||||||
|
user = await self._unit_of_work.user_repository.get_user_by_email(email)
|
||||||
|
user_id = user.id
|
||||||
await self._unit_of_work.user_repository.set_password(
|
await self._unit_of_work.user_repository.set_password(
|
||||||
user_id=user.id,
|
user_id=user_id,
|
||||||
password_hash=password_hash,
|
password_hash=password_hash,
|
||||||
)
|
)
|
||||||
self._logger.info(f'Set password for user {user.id}')
|
self._logger.info(f'Set password for user {user_id}')
|
||||||
return True
|
return True
|
||||||
except ApplicationException:
|
except ApplicationException:
|
||||||
raise
|
raise
|
||||||
|
|||||||
@@ -1,62 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from ulid import ULID
|
|
||||||
|
|
||||||
from src.application.abstractions import IUnitOfWork
|
|
||||||
from src.application.contracts import ILogger
|
|
||||||
from src.application.domain.entities.organization import OrganizationDocumentEntity
|
|
||||||
from src.application.domain.exceptions import ApplicationException
|
|
||||||
from src.infrastructure.database.decorators import transactional
|
|
||||||
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
|
||||||
|
|
||||||
|
|
||||||
ALLOWED_DOCUMENT_TYPES = frozenset({
|
|
||||||
'charter', 'inn_certificate', 'ogrn_certificate',
|
|
||||||
'bank_details', 'kyc_representative', 'power_of_attorney', 'other',
|
|
||||||
})
|
|
||||||
|
|
||||||
|
|
||||||
class UploadOrganizationDocumentCommand:
|
|
||||||
def __init__(
|
|
||||||
self,
|
|
||||||
unit_of_work: IUnitOfWork,
|
|
||||||
s3_service: S3DocumentsService,
|
|
||||||
logger: ILogger,
|
|
||||||
):
|
|
||||||
self._unit_of_work = unit_of_work
|
|
||||||
self._s3 = s3_service
|
|
||||||
self._logger = logger
|
|
||||||
|
|
||||||
@transactional
|
|
||||||
async def __call__(
|
|
||||||
self,
|
|
||||||
*,
|
|
||||||
organization_id: str,
|
|
||||||
admin_user_id: str,
|
|
||||||
document_type: str,
|
|
||||||
file_name: str,
|
|
||||||
content_type: str,
|
|
||||||
body: bytes,
|
|
||||||
) -> OrganizationDocumentEntity:
|
|
||||||
if document_type not in ALLOWED_DOCUMENT_TYPES:
|
|
||||||
raise ApplicationException(status_code=400, message='Invalid document type')
|
|
||||||
|
|
||||||
await self._unit_of_work.legal_entity_repository.get_by_id(organization_id)
|
|
||||||
|
|
||||||
document_id = str(ULID())
|
|
||||||
s3_key = self._s3.build_object_key(organization_id, document_id, file_name)
|
|
||||||
await self._s3.upload_bytes(key=s3_key, body=body, content_type=content_type)
|
|
||||||
|
|
||||||
entity = OrganizationDocumentEntity(
|
|
||||||
id=document_id,
|
|
||||||
organization_id=organization_id,
|
|
||||||
document_type=document_type,
|
|
||||||
file_name=file_name,
|
|
||||||
s3_key=s3_key,
|
|
||||||
content_type=content_type,
|
|
||||||
file_size_bytes=len(body),
|
|
||||||
uploaded_by=admin_user_id,
|
|
||||||
)
|
|
||||||
saved = await self._unit_of_work.organization_document_repository.create(entity)
|
|
||||||
self._logger.info(f'Document uploaded id={saved.id} org={organization_id}')
|
|
||||||
return saved
|
|
||||||
51
src/application/commands/user_commands.py
Normal file
51
src/application/commands/user_commands.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.user import UserEntity
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(cursor: str | None) -> datetime | None:
|
||||||
|
if not cursor:
|
||||||
|
return None
|
||||||
|
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class ListUsersCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int = 50,
|
||||||
|
offset: int = 0,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> tuple[list[UserEntity], int]:
|
||||||
|
items = await self._unit_of_work.user_repository.list_all(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
search=search,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
total = await self._unit_of_work.user_repository.count_all(search=search)
|
||||||
|
return items, total
|
||||||
|
|
||||||
|
|
||||||
|
class GetUserCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, user_id: str) -> UserEntity:
|
||||||
|
return await self._unit_of_work.user_repository.get_by_id(user_id)
|
||||||
63
src/application/commands/user_wallet_commands.py
Normal file
63
src/application/commands/user_wallet_commands.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.user import UserEntity, WalletEntity
|
||||||
|
from src.application.domain.exceptions import ApplicationException
|
||||||
|
from src.infrastructure.crypto.wallet_crypto import (
|
||||||
|
DerivedSecretKey,
|
||||||
|
decrypt_mnemonic,
|
||||||
|
derive_all_private_keys,
|
||||||
|
is_crypto_ready,
|
||||||
|
)
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class ListUserWalletsCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, user_id: str) -> list[WalletEntity]:
|
||||||
|
await self._unit_of_work.user_repository.get_by_id(user_id)
|
||||||
|
return await self._unit_of_work.wallet_repository.list_by_user(user_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GetUserMnemonicCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, user_id: str) -> str:
|
||||||
|
user = await self._require_wallets_user(user_id)
|
||||||
|
if not is_crypto_ready():
|
||||||
|
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
||||||
|
return decrypt_mnemonic(user.encrypted_mnemonic or '')
|
||||||
|
|
||||||
|
async def _require_wallets_user(self, user_id: str) -> UserEntity:
|
||||||
|
user = await self._unit_of_work.user_repository.get_by_id(user_id)
|
||||||
|
if not user.encrypted_mnemonic:
|
||||||
|
raise ApplicationException(status_code=404, message='Wallets not created for user')
|
||||||
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
class GetUserSecretKeysCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, user_id: str) -> list[DerivedSecretKey]:
|
||||||
|
user = await self._require_wallets_user(user_id)
|
||||||
|
if not is_crypto_ready():
|
||||||
|
raise ApplicationException(status_code=503, message='Crypto service not ready')
|
||||||
|
mnemonic = decrypt_mnemonic(user.encrypted_mnemonic or '')
|
||||||
|
return derive_all_private_keys(mnemonic)
|
||||||
|
|
||||||
|
async def _require_wallets_user(self, user_id: str) -> UserEntity:
|
||||||
|
user = await self._unit_of_work.user_repository.get_by_id(user_id)
|
||||||
|
if not user.encrypted_mnemonic:
|
||||||
|
raise ApplicationException(status_code=404, message='Wallets not created for user')
|
||||||
|
return user
|
||||||
@@ -1,5 +1,8 @@
|
|||||||
from src.application.domain.entities.user import UserEntity
|
from src.application.domain.entities.user import UserEntity
|
||||||
from src.application.domain.entities.admin_user import AdminUserEntity
|
from src.application.domain.entities.admin_user import AdminUserEntity
|
||||||
from src.application.domain.entities.admin_session import AdminSessionEntity
|
from src.application.domain.entities.admin_session import AdminSessionEntity
|
||||||
|
from src.application.domain.entities.order import OrderEntity
|
||||||
|
from src.application.domain.entities.organization import PurchaseRequestEntity
|
||||||
|
|
||||||
__all__ = ['UserEntity', 'AdminUserEntity', 'AdminSessionEntity']
|
__all__ = ['UserEntity', 'AdminUserEntity', 'AdminSessionEntity', 'OrderEntity', 'PurchaseRequestEntity']
|
||||||
|
|
||||||
36
src/application/domain/entities/order.py
Normal file
36
src/application/domain/entities/order.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class OrderEntity:
|
||||||
|
id: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
user_id: str | None = None
|
||||||
|
usdt_amount: Decimal | None = None
|
||||||
|
usdt_exchange_rate: Decimal | None = None
|
||||||
|
gas_fee: Decimal | None = None
|
||||||
|
total_price: Decimal | None = None
|
||||||
|
service_fee: Decimal | None = None
|
||||||
|
status: OrderStatus | None = None
|
||||||
|
|
||||||
|
client_payment_id: str | None = None
|
||||||
|
|
||||||
|
itpay_payment_qr_url_desktop: str | None = None
|
||||||
|
itpay_payment_qr_url_android: str | None = None
|
||||||
|
itpay_payment_qr_url_ios: str | None = None
|
||||||
|
|
||||||
|
itpay_payment_qr_image_desktop: str | None = None
|
||||||
|
itpay_payment_qr_image_android: str | None = None
|
||||||
|
itpay_payment_qr_image_ios: str | None = None
|
||||||
|
|
||||||
|
itpay_id: str | None = None
|
||||||
|
itpay_qr_id: str | None = None
|
||||||
|
itpay_amount: Decimal | None = None
|
||||||
|
itpay_created_at: datetime | None = None
|
||||||
|
|
||||||
@@ -24,6 +24,7 @@ class LegalEntityEntity:
|
|||||||
kyc_verified: bool
|
kyc_verified: bool
|
||||||
kyc_verified_at: datetime | None
|
kyc_verified_at: datetime | None
|
||||||
encrypted_mnemonic: str | None
|
encrypted_mnemonic: str | None
|
||||||
|
document_s3_keys: dict[str, str | None]
|
||||||
created_by: str | None
|
created_by: str | None
|
||||||
created_at: datetime | None = None
|
created_at: datetime | None = None
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
@@ -40,16 +41,19 @@ class OrganizationWalletEntity:
|
|||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class OrganizationDocumentEntity:
|
class CreateOrganizationWalletsResult:
|
||||||
id: str
|
wallets: list[OrganizationWalletEntity]
|
||||||
|
mnemonic: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class OrganizationDocumentSlot:
|
||||||
organization_id: str
|
organization_id: str
|
||||||
document_type: str
|
document_type: str
|
||||||
file_name: str
|
s3_key: str | None
|
||||||
s3_key: str
|
file_name: str | None = None
|
||||||
content_type: str
|
content_type: str | None = None
|
||||||
file_size_bytes: int
|
file_size_bytes: int | None = None
|
||||||
uploaded_by: str | None
|
|
||||||
created_at: datetime | None = None
|
|
||||||
|
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
@@ -57,8 +61,8 @@ class PurchaseRequestEntity:
|
|||||||
id: str
|
id: str
|
||||||
organization_id: str
|
organization_id: str
|
||||||
status: str
|
status: str
|
||||||
usdt_amount: Decimal
|
rub_amount: Decimal
|
||||||
rub_amount: Decimal | None
|
usdt_amount: Decimal | None
|
||||||
exchange_rate: Decimal | None
|
exchange_rate: Decimal | None
|
||||||
service_fee_percent: Decimal | None
|
service_fee_percent: Decimal | None
|
||||||
comment: str | None
|
comment: str | None
|
||||||
|
|||||||
17
src/application/domain/entities/party_search.py
Normal file
17
src/application/domain/entities/party_search.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PartySearchEntity:
|
||||||
|
id: str
|
||||||
|
account_type: str
|
||||||
|
user_id: str
|
||||||
|
email: str | None
|
||||||
|
name: str | None
|
||||||
|
inn: str | None
|
||||||
|
phone: str | None
|
||||||
|
status: str | None = None
|
||||||
|
kyc_verified: bool | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
48
src/application/domain/entities/risk.py
Normal file
48
src/application/domain/entities/risk.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskAssessmentEntity:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuditEventEntity:
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskSummaryEntity:
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FunnelEntity:
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
@@ -32,3 +32,12 @@ class UserEntity:
|
|||||||
account_type: str | None = None
|
account_type: str | None = None
|
||||||
provisioned_by: str | None = None
|
provisioned_by: str | None = None
|
||||||
provisioned_at: datetime | None = None
|
provisioned_at: datetime | None = None
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WalletEntity:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
derivation_path: str
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|||||||
@@ -1,2 +1,3 @@
|
|||||||
from src.application.domain.enums.log_level import LogLevel
|
from src.application.domain.enums.log_level import LogLevel
|
||||||
from src.application.domain.enums.log_format import LogFormat
|
from src.application.domain.enums.log_format import LogFormat
|
||||||
|
from src.application.domain.enums.order_status import OrderStatus
|
||||||
9
src/application/domain/enums/order_status.py
Normal file
9
src/application/domain/enums/order_status.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(str, Enum):
|
||||||
|
PENDING = 'pending'
|
||||||
|
REJECTED = 'rejected'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
CANCELLED = 'cancelled'
|
||||||
|
ERROR = 'error'
|
||||||
35
src/application/domain/organization_documents.py
Normal file
35
src/application/domain/organization_documents.py
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
ORGANIZATION_DOCUMENT_TYPES: tuple[str, ...] = (
|
||||||
|
'charter',
|
||||||
|
'inn_certificate',
|
||||||
|
'ogrn_certificate',
|
||||||
|
'bank_details',
|
||||||
|
'kyc_representative',
|
||||||
|
'power_of_attorney',
|
||||||
|
'other',
|
||||||
|
)
|
||||||
|
|
||||||
|
DOCUMENT_TYPE_TO_COLUMN: dict[str, str] = {
|
||||||
|
'charter': 'charter_s3_key',
|
||||||
|
'inn_certificate': 'inn_certificate_s3_key',
|
||||||
|
'ogrn_certificate': 'ogrn_certificate_s3_key',
|
||||||
|
'bank_details': 'bank_details_document_s3_key',
|
||||||
|
'kyc_representative': 'kyc_representative_s3_key',
|
||||||
|
'power_of_attorney': 'power_of_attorney_s3_key',
|
||||||
|
'other': 'other_document_s3_key',
|
||||||
|
}
|
||||||
|
|
||||||
|
DOCUMENT_TYPE_URL_SLUGS: dict[str, str] = {
|
||||||
|
'charter': 'charter',
|
||||||
|
'inn_certificate': 'inn-certificate',
|
||||||
|
'ogrn_certificate': 'ogrn-certificate',
|
||||||
|
'bank_details': 'bank-details',
|
||||||
|
'kyc_representative': 'kyc-representative',
|
||||||
|
'power_of_attorney': 'power-of-attorney',
|
||||||
|
'other': 'other',
|
||||||
|
}
|
||||||
|
|
||||||
|
URL_SLUG_TO_DOCUMENT_TYPE: dict[str, str] = {
|
||||||
|
slug: document_type for document_type, slug in DOCUMENT_TYPE_URL_SLUGS.items()
|
||||||
|
}
|
||||||
@@ -81,7 +81,7 @@ class Settings(BaseSettings):
|
|||||||
RATE_LIMIT_WINDOW: int = 60
|
RATE_LIMIT_WINDOW: int = 60
|
||||||
|
|
||||||
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'] = 'JSON'
|
||||||
|
|
||||||
@field_validator('VAULT_ADDR', mode='before')
|
@field_validator('VAULT_ADDR', mode='before')
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -260,7 +260,7 @@ class Settings(BaseSettings):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def EXCLUDED_PATHS(self) -> List[str]:
|
def EXCLUDED_PATHS(self) -> List[str]:
|
||||||
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health']
|
return ['/docs', '/redoc', '/openapi.json', '/ping', '/health', '/healthcheck']
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=1)
|
@lru_cache(maxsize=1)
|
||||||
|
|||||||
@@ -43,6 +43,14 @@ class DerivedWallet:
|
|||||||
derivation_path: str
|
derivation_path: str
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class DerivedSecretKey:
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
derivation_path: str
|
||||||
|
private_key: str
|
||||||
|
|
||||||
|
|
||||||
class CryptoNotReadyError(RuntimeError):
|
class CryptoNotReadyError(RuntimeError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
@@ -151,6 +159,87 @@ def derive_all_addresses(mnemonic: str) -> list[DerivedWallet]:
|
|||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def derive_all_private_keys(mnemonic: str) -> list[DerivedSecretKey]:
|
||||||
|
seed_bytes = Bip39SeedGenerator(mnemonic).Generate()
|
||||||
|
|
||||||
|
eth_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.ETHEREUM)
|
||||||
|
eth_node = (
|
||||||
|
eth_ctx.Purpose()
|
||||||
|
.Coin()
|
||||||
|
.Account(0)
|
||||||
|
.Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
.AddressIndex(0)
|
||||||
|
)
|
||||||
|
eth_addr = eth_node.PublicKey().ToAddress()
|
||||||
|
eth_pk = eth_node.PrivateKey().Raw().ToHex()
|
||||||
|
|
||||||
|
btc_ctx = Bip84.FromSeed(seed_bytes, Bip84Coins.BITCOIN)
|
||||||
|
btc_node = (
|
||||||
|
btc_ctx.Purpose()
|
||||||
|
.Coin()
|
||||||
|
.Account(0)
|
||||||
|
.Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
.AddressIndex(0)
|
||||||
|
)
|
||||||
|
btc_addr = btc_node.PublicKey().ToAddress()
|
||||||
|
btc_pk = btc_node.PrivateKey().Raw().ToHex()
|
||||||
|
|
||||||
|
trx_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.TRON)
|
||||||
|
trx_node = (
|
||||||
|
trx_ctx.Purpose()
|
||||||
|
.Coin()
|
||||||
|
.Account(0)
|
||||||
|
.Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
.AddressIndex(0)
|
||||||
|
)
|
||||||
|
trx_addr = trx_node.PublicKey().ToAddress()
|
||||||
|
trx_pk = trx_node.PrivateKey().Raw().ToHex()
|
||||||
|
|
||||||
|
sol_ctx = Bip44.FromSeed(seed_bytes, Bip44Coins.SOLANA)
|
||||||
|
sol_node = (
|
||||||
|
sol_ctx.Purpose()
|
||||||
|
.Coin()
|
||||||
|
.Account(0)
|
||||||
|
.Change(Bip44Changes.CHAIN_EXT)
|
||||||
|
.AddressIndex(0)
|
||||||
|
)
|
||||||
|
sol_addr = sol_node.PublicKey().ToAddress()
|
||||||
|
sol_pk = sol_node.PrivateKey().Raw().ToHex()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DerivedSecretKey(
|
||||||
|
chain='ETH',
|
||||||
|
address=eth_addr,
|
||||||
|
derivation_path=DERIVATION_PATHS['ETH'],
|
||||||
|
private_key=eth_pk,
|
||||||
|
),
|
||||||
|
DerivedSecretKey(
|
||||||
|
chain='BSC',
|
||||||
|
address=eth_addr,
|
||||||
|
derivation_path=DERIVATION_PATHS['BSC'],
|
||||||
|
private_key=eth_pk,
|
||||||
|
),
|
||||||
|
DerivedSecretKey(
|
||||||
|
chain='BTC',
|
||||||
|
address=btc_addr,
|
||||||
|
derivation_path=DERIVATION_PATHS['BTC'],
|
||||||
|
private_key=btc_pk,
|
||||||
|
),
|
||||||
|
DerivedSecretKey(
|
||||||
|
chain='TRX',
|
||||||
|
address=trx_addr,
|
||||||
|
derivation_path=DERIVATION_PATHS['TRX'],
|
||||||
|
private_key=trx_pk,
|
||||||
|
),
|
||||||
|
DerivedSecretKey(
|
||||||
|
chain='SOL',
|
||||||
|
address=sol_addr,
|
||||||
|
derivation_path=DERIVATION_PATHS['SOL'],
|
||||||
|
private_key=sol_pk,
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def encrypt_mnemonic(plaintext: str) -> str:
|
def encrypt_mnemonic(plaintext: str) -> str:
|
||||||
if _master_key is None:
|
if _master_key is None:
|
||||||
raise CryptoNotReadyError('Crypto service not ready')
|
raise CryptoNotReadyError('Crypto service not ready')
|
||||||
|
|||||||
@@ -4,8 +4,10 @@ from src.infrastructure.database.models.admin_user import AdminUserModel
|
|||||||
from src.infrastructure.database.models.admin_session import AdminSessionModel
|
from src.infrastructure.database.models.admin_session import AdminSessionModel
|
||||||
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
from src.infrastructure.database.models.legal_entity import LegalEntityModel
|
||||||
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel
|
from src.infrastructure.database.models.organization_wallet import OrganizationWalletModel
|
||||||
from src.infrastructure.database.models.organization_document import OrganizationDocumentModel
|
|
||||||
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
|
from src.infrastructure.database.models.purchase_request import PurchaseRequestModel
|
||||||
|
from src.infrastructure.database.models.wallets import WalletModel
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Base',
|
'Base',
|
||||||
@@ -14,6 +16,7 @@ __all__ = [
|
|||||||
'AdminSessionModel',
|
'AdminSessionModel',
|
||||||
'LegalEntityModel',
|
'LegalEntityModel',
|
||||||
'OrganizationWalletModel',
|
'OrganizationWalletModel',
|
||||||
'OrganizationDocumentModel',
|
|
||||||
'PurchaseRequestModel',
|
'PurchaseRequestModel',
|
||||||
|
'WalletModel',
|
||||||
|
'OrderModel',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -35,6 +35,13 @@ class LegalEntityModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
|||||||
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
|
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='true', default=True)
|
||||||
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
kyc_verified_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||||
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
|
encrypted_mnemonic: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
charter_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
inn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
ogrn_certificate_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
bank_details_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
kyc_representative_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
power_of_attorney_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
|
other_document_s3_key: Mapped[str | None] = mapped_column(String(1024), nullable=True)
|
||||||
created_by: Mapped[str | None] = mapped_column(
|
created_by: Mapped[str | None] = mapped_column(
|
||||||
String(26),
|
String(26),
|
||||||
ForeignKey('admin_users.id'),
|
ForeignKey('admin_users.id'),
|
||||||
|
|||||||
53
src/infrastructure/database/models/order.py
Normal file
53
src/infrastructure/database/models/order.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Numeric, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OrderModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||||
|
__tablename__ = 'orders'
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(26),
|
||||||
|
ForeignKey('users.id', ondelete='RESTRICT'),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
usdt_exchange_rate: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
gas_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
total_price: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
service_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
status: Mapped[OrderStatus] = mapped_column(
|
||||||
|
SAEnum(OrderStatus,name='order_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
default=OrderStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
client_payment_id: Mapped[str] = mapped_column(
|
||||||
|
String(26),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
itpay_payment_qr_url_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_url_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_url_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
itpay_payment_qr_image_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_image_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_image_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
itpay_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
itpay_qr_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
itpay_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
itpay_created_at: Mapped[DateTime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,35 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from datetime import datetime
|
|
||||||
|
|
||||||
from sqlalchemy import BigInteger, DateTime, ForeignKey, String, func
|
|
||||||
from sqlalchemy.orm import Mapped, mapped_column
|
|
||||||
|
|
||||||
from src.infrastructure.database.models.base import Base
|
|
||||||
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationDocumentModel(Base, UlidPrimaryKeyMixin):
|
|
||||||
__tablename__ = 'organization_documents'
|
|
||||||
|
|
||||||
organization_id: Mapped[str] = mapped_column(
|
|
||||||
String(26),
|
|
||||||
ForeignKey('legal_entities.id', ondelete='RESTRICT'),
|
|
||||||
nullable=False,
|
|
||||||
index=True,
|
|
||||||
)
|
|
||||||
document_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
|
||||||
file_name: Mapped[str] = mapped_column(String(512), nullable=False)
|
|
||||||
s3_key: Mapped[str] = mapped_column(String(1024), nullable=False)
|
|
||||||
content_type: Mapped[str] = mapped_column(String(128), nullable=False)
|
|
||||||
file_size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
|
||||||
uploaded_by: Mapped[str | None] = mapped_column(
|
|
||||||
String(26),
|
|
||||||
ForeignKey('admin_users.id'),
|
|
||||||
nullable=True,
|
|
||||||
)
|
|
||||||
created_at: Mapped[datetime] = mapped_column(
|
|
||||||
DateTime(timezone=True),
|
|
||||||
nullable=False,
|
|
||||||
server_default=func.now(),
|
|
||||||
)
|
|
||||||
@@ -20,8 +20,8 @@ class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
|
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
|
||||||
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
rub_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
||||||
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
usdt_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||||
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||||
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
32
src/infrastructure/database/models/risk.py
Normal file
32
src/infrastructure/database/models/risk.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||||
|
__tablename__ = "risk_assessments"
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(String(26), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
order_id: Mapped[str | None] = mapped_column(String(26), ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False, default="individual", server_default="individual", index=True)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
decision: Mapped[str] = mapped_column(String(32), nullable=False, default="allow", index=True)
|
||||||
|
reasons: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"))
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventModel(Base, UlidPrimaryKeyMixin):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32), nullable=False, default="system", server_default="system", index=True)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", server_default="info", index=True)
|
||||||
|
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||||
|
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
28
src/infrastructure/database/models/wallets.py
Normal file
28
src/infrastructure/database/models/wallets.py
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, String, func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class WalletModel(Base, UlidPrimaryKeyMixin):
|
||||||
|
__tablename__ = 'wallets'
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(26),
|
||||||
|
ForeignKey('users.id', ondelete='RESTRICT'),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
chain: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||||
|
address: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
derivation_path: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False,
|
||||||
|
server_default=func.now(),
|
||||||
|
)
|
||||||
@@ -3,8 +3,10 @@ from src.infrastructure.database.repositories.admin_session_repository import Ad
|
|||||||
from src.infrastructure.database.repositories.user_repository import UserRepository
|
from src.infrastructure.database.repositories.user_repository import UserRepository
|
||||||
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
|
from src.infrastructure.database.repositories.legal_entity_repository import LegalEntityRepository
|
||||||
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
|
from src.infrastructure.database.repositories.organization_wallet_repository import OrganizationWalletRepository
|
||||||
from src.infrastructure.database.repositories.organization_document_repository import OrganizationDocumentRepository
|
|
||||||
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
|
from src.infrastructure.database.repositories.purchase_request_repository import PurchaseRequestRepository
|
||||||
|
from src.infrastructure.database.repositories.wallets_repository import WalletRepository
|
||||||
|
from src.infrastructure.database.repositories.orders_repository import OrderRepository
|
||||||
|
from src.infrastructure.database.repositories.risk_repository import RiskRepository
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'AdminUserRepository',
|
'AdminUserRepository',
|
||||||
@@ -12,6 +14,7 @@ __all__ = [
|
|||||||
'UserRepository',
|
'UserRepository',
|
||||||
'LegalEntityRepository',
|
'LegalEntityRepository',
|
||||||
'OrganizationWalletRepository',
|
'OrganizationWalletRepository',
|
||||||
'OrganizationDocumentRepository',
|
|
||||||
'PurchaseRequestRepository',
|
'PurchaseRequestRepository',
|
||||||
|
'WalletRepository',
|
||||||
|
'OrderRepository',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from fastapi import status
|
from fastapi import status
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from src.application.abstractions.repositories import IAdminUserRepository
|
from src.application.abstractions.repositories import IAdminUserRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
|
|||||||
@@ -2,8 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from fastapi import status
|
from sqlalchemy import func, or_, select, update
|
||||||
from sqlalchemy import func, select, update
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
|
||||||
@@ -11,7 +10,8 @@ from src.application.abstractions.repositories import ILegalEntityRepository
|
|||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities.organization import LegalEntityEntity
|
from src.application.domain.entities.organization import LegalEntityEntity
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException
|
||||||
from src.infrastructure.database.models import LegalEntityModel
|
from src.application.domain.organization_documents import DOCUMENT_TYPE_TO_COLUMN, ORGANIZATION_DOCUMENT_TYPES
|
||||||
|
from src.infrastructure.database.models import LegalEntityModel, UserModel
|
||||||
|
|
||||||
|
|
||||||
class LegalEntityRepository(ILegalEntityRepository):
|
class LegalEntityRepository(ILegalEntityRepository):
|
||||||
@@ -19,6 +19,12 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
self._session = session
|
self._session = session
|
||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
|
def _document_s3_keys_from_model(self, m: LegalEntityModel) -> dict[str, str | None]:
|
||||||
|
return {
|
||||||
|
document_type: getattr(m, DOCUMENT_TYPE_TO_COLUMN[document_type])
|
||||||
|
for document_type in ORGANIZATION_DOCUMENT_TYPES
|
||||||
|
}
|
||||||
|
|
||||||
def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
|
def _to_entity(self, m: LegalEntityModel) -> LegalEntityEntity:
|
||||||
return LegalEntityEntity(
|
return LegalEntityEntity(
|
||||||
id=m.id,
|
id=m.id,
|
||||||
@@ -37,11 +43,43 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
kyc_verified=m.kyc_verified,
|
kyc_verified=m.kyc_verified,
|
||||||
kyc_verified_at=m.kyc_verified_at,
|
kyc_verified_at=m.kyc_verified_at,
|
||||||
encrypted_mnemonic=m.encrypted_mnemonic,
|
encrypted_mnemonic=m.encrypted_mnemonic,
|
||||||
|
document_s3_keys=self._document_s3_keys_from_model(m),
|
||||||
created_by=m.created_by,
|
created_by=m.created_by,
|
||||||
created_at=m.created_at,
|
created_at=m.created_at,
|
||||||
updated_at=m.updated_at,
|
updated_at=m.updated_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def _search_filter(self, search: str | None):
|
||||||
|
if not search or not search.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = f'%{search.strip()}%'
|
||||||
|
full_name = func.concat_ws(
|
||||||
|
' ',
|
||||||
|
UserModel.last_name,
|
||||||
|
UserModel.first_name,
|
||||||
|
UserModel.middle_name,
|
||||||
|
)
|
||||||
|
return or_(
|
||||||
|
LegalEntityModel.id.ilike(pattern),
|
||||||
|
LegalEntityModel.name.ilike(pattern),
|
||||||
|
LegalEntityModel.short_name.ilike(pattern),
|
||||||
|
LegalEntityModel.inn.ilike(pattern),
|
||||||
|
LegalEntityModel.ogrn.ilike(pattern),
|
||||||
|
LegalEntityModel.kpp.ilike(pattern),
|
||||||
|
LegalEntityModel.legal_address.ilike(pattern),
|
||||||
|
LegalEntityModel.actual_address.ilike(pattern),
|
||||||
|
LegalEntityModel.contact_person.ilike(pattern),
|
||||||
|
LegalEntityModel.contact_phone.ilike(pattern),
|
||||||
|
UserModel.email.ilike(pattern),
|
||||||
|
UserModel.last_name.ilike(pattern),
|
||||||
|
UserModel.first_name.ilike(pattern),
|
||||||
|
UserModel.middle_name.ilike(pattern),
|
||||||
|
UserModel.phone.ilike(pattern),
|
||||||
|
UserModel.inn.ilike(pattern),
|
||||||
|
full_name.ilike(pattern),
|
||||||
|
)
|
||||||
|
|
||||||
async def create(
|
async def create(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
@@ -95,14 +133,37 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
raise ApplicationException(status_code=404, message='Organization not found')
|
raise ApplicationException(status_code=404, message='Organization not found')
|
||||||
return self._to_entity(m)
|
return self._to_entity(m)
|
||||||
|
|
||||||
async def list_all(self, *, limit: int, offset: int) -> list[LegalEntityEntity]:
|
async def list_all(
|
||||||
res = await self._session.execute(
|
self,
|
||||||
select(LegalEntityModel).order_by(LegalEntityModel.created_at.desc()).limit(limit).offset(offset)
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> list[LegalEntityEntity]:
|
||||||
|
stmt = (
|
||||||
|
select(LegalEntityModel)
|
||||||
|
.join(UserModel, UserModel.id == LegalEntityModel.user_id)
|
||||||
|
.where(UserModel.is_deleted.is_(False))
|
||||||
|
.order_by(LegalEntityModel.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
)
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
return [self._to_entity(m) for m in res.scalars().all()]
|
return [self._to_entity(m) for m in res.scalars().all()]
|
||||||
|
|
||||||
async def count_all(self) -> int:
|
async def count_all(self, *, search: str | None = None) -> int:
|
||||||
res = await self._session.execute(select(func.count()).select_from(LegalEntityModel))
|
stmt = (
|
||||||
|
select(func.count(LegalEntityModel.id))
|
||||||
|
.join(UserModel, UserModel.id == LegalEntityModel.user_id)
|
||||||
|
.where(UserModel.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
return int(res.scalar_one())
|
return int(res.scalar_one())
|
||||||
|
|
||||||
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
|
async def update(self, organization_id: str, *, values: dict[str, Any]) -> LegalEntityEntity:
|
||||||
@@ -121,3 +182,19 @@ class LegalEntityRepository(ILegalEntityRepository):
|
|||||||
.values(encrypted_mnemonic=encrypted_mnemonic)
|
.values(encrypted_mnemonic=encrypted_mnemonic)
|
||||||
)
|
)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
|
|
||||||
|
async def get_document_s3_key(self, organization_id: str, document_type: str) -> str | None:
|
||||||
|
org = await self.get_by_id(organization_id)
|
||||||
|
return org.document_s3_keys.get(document_type)
|
||||||
|
|
||||||
|
async def set_document_s3_key(self, organization_id: str, document_type: str, s3_key: str) -> LegalEntityEntity:
|
||||||
|
column = DOCUMENT_TYPE_TO_COLUMN.get(document_type)
|
||||||
|
if column is None:
|
||||||
|
raise ApplicationException(status_code=400, message='Invalid document type')
|
||||||
|
await self._session.execute(
|
||||||
|
update(LegalEntityModel)
|
||||||
|
.where(LegalEntityModel.id == organization_id)
|
||||||
|
.values(**{column: s3_key})
|
||||||
|
)
|
||||||
|
await self._session.flush()
|
||||||
|
return await self.get_by_id(organization_id)
|
||||||
|
|||||||
220
src/infrastructure/database/repositories/orders_repository.py
Normal file
220
src/infrastructure/database/repositories/orders_repository.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import replace
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import desc, select, or_, extract, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.order import OrderEntity
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRepository(IOrderRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_entity(model: OrderModel) -> OrderEntity:
|
||||||
|
return OrderEntity(
|
||||||
|
id=model.id,
|
||||||
|
created_at=model.created_at,
|
||||||
|
updated_at=model.updated_at,
|
||||||
|
user_id=model.user_id,
|
||||||
|
usdt_amount=model.usdt_amount,
|
||||||
|
usdt_exchange_rate=model.usdt_exchange_rate,
|
||||||
|
gas_fee=model.gas_fee,
|
||||||
|
total_price=model.total_price,
|
||||||
|
service_fee=model.service_fee,
|
||||||
|
status=model.status,
|
||||||
|
client_payment_id=model.client_payment_id,
|
||||||
|
itpay_payment_qr_url_desktop=model.itpay_payment_qr_url_desktop,
|
||||||
|
itpay_payment_qr_url_android=model.itpay_payment_qr_url_android,
|
||||||
|
itpay_payment_qr_url_ios=model.itpay_payment_qr_url_ios,
|
||||||
|
itpay_payment_qr_image_desktop=model.itpay_payment_qr_image_desktop,
|
||||||
|
itpay_payment_qr_image_android=model.itpay_payment_qr_image_android,
|
||||||
|
itpay_payment_qr_image_ios=model.itpay_payment_qr_image_ios,
|
||||||
|
itpay_id=model.itpay_id,
|
||||||
|
itpay_qr_id=model.itpay_qr_id,
|
||||||
|
itpay_amount=model.itpay_amount,
|
||||||
|
itpay_created_at=model.itpay_created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_filter(self, search: str | None):
|
||||||
|
if not search or not search.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = f'%{search.strip()}%'
|
||||||
|
return or_(
|
||||||
|
OrderModel.id.ilike(pattern),
|
||||||
|
OrderModel.user_id.ilike(pattern),
|
||||||
|
OrderModel.status.ilike(pattern),
|
||||||
|
OrderModel.client_payment_id.ilike(pattern),
|
||||||
|
OrderModel.itpay_id.ilike(pattern),
|
||||||
|
OrderModel.itpay_qr_id.ilike(pattern),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id(self,order_id: str) -> OrderEntity | None:
|
||||||
|
stmt=select(OrderModel).where(OrderModel.id==order_id)
|
||||||
|
model=await self._session.scalar(stmt)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
return self._to_entity(model)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_user_id(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
cursor_created_at=None
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.where(OrderModel.user_id==user_id)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
result=await self._session.scalars(stmt)
|
||||||
|
return [self._to_entity(model) for model in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor_created_at=None
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return [self._to_entity(model) for model in res.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_date(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
cursor_created_at=None,
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return [self._to_entity(model) for model in res.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_by_month(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> Decimal:
|
||||||
|
stmt = (
|
||||||
|
select(func.coalesce(func.sum(OrderModel.service_fee), 0))
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
OrderModel.status == OrderStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return res.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_by_day(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
day: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> Decimal:
|
||||||
|
stmt = (
|
||||||
|
select(func.coalesce(func.sum(OrderModel.service_fee), 0))
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
extract("day", OrderModel.created_at) == day,
|
||||||
|
OrderModel.status == OrderStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return res.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all(self,*,search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_by_month(self,*,year: int,month: int, search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
stmt = stmt.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_by_day(self,*,year: int,month: int,day: int, search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
stmt = stmt.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
extract("day", OrderModel.created_at) == day
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
@@ -1,66 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
|
||||||
|
|
||||||
from src.application.abstractions.repositories import IOrganizationDocumentRepository
|
|
||||||
from src.application.contracts import ILogger
|
|
||||||
from src.application.domain.entities.organization import OrganizationDocumentEntity
|
|
||||||
from src.application.domain.exceptions import ApplicationException
|
|
||||||
from src.infrastructure.database.models import OrganizationDocumentModel
|
|
||||||
|
|
||||||
|
|
||||||
class OrganizationDocumentRepository(IOrganizationDocumentRepository):
|
|
||||||
def __init__(self, session: AsyncSession, logger: ILogger):
|
|
||||||
self._session = session
|
|
||||||
self._logger = logger
|
|
||||||
|
|
||||||
def _to_entity(self, m: OrganizationDocumentModel) -> OrganizationDocumentEntity:
|
|
||||||
return OrganizationDocumentEntity(
|
|
||||||
id=m.id,
|
|
||||||
organization_id=m.organization_id,
|
|
||||||
document_type=m.document_type,
|
|
||||||
file_name=m.file_name,
|
|
||||||
s3_key=m.s3_key,
|
|
||||||
content_type=m.content_type,
|
|
||||||
file_size_bytes=m.file_size_bytes,
|
|
||||||
uploaded_by=m.uploaded_by,
|
|
||||||
created_at=m.created_at,
|
|
||||||
)
|
|
||||||
|
|
||||||
async def create(self, document: OrganizationDocumentEntity) -> OrganizationDocumentEntity:
|
|
||||||
model = OrganizationDocumentModel(
|
|
||||||
id=document.id,
|
|
||||||
organization_id=document.organization_id,
|
|
||||||
document_type=document.document_type,
|
|
||||||
file_name=document.file_name,
|
|
||||||
s3_key=document.s3_key,
|
|
||||||
content_type=document.content_type,
|
|
||||||
file_size_bytes=document.file_size_bytes,
|
|
||||||
uploaded_by=document.uploaded_by,
|
|
||||||
)
|
|
||||||
self._session.add(model)
|
|
||||||
try:
|
|
||||||
await self._session.flush()
|
|
||||||
return self._to_entity(model)
|
|
||||||
except SQLAlchemyError as exc:
|
|
||||||
self._logger.exception(str(exc))
|
|
||||||
raise ApplicationException(status_code=500, message='Database error')
|
|
||||||
|
|
||||||
async def get_by_id(self, document_id: str) -> OrganizationDocumentEntity:
|
|
||||||
res = await self._session.execute(
|
|
||||||
select(OrganizationDocumentModel).where(OrganizationDocumentModel.id == document_id)
|
|
||||||
)
|
|
||||||
m = res.scalar_one_or_none()
|
|
||||||
if m is None:
|
|
||||||
raise ApplicationException(status_code=404, message='Document not found')
|
|
||||||
return self._to_entity(m)
|
|
||||||
|
|
||||||
async def list_by_organization(self, organization_id: str) -> list[OrganizationDocumentEntity]:
|
|
||||||
res = await self._session.execute(
|
|
||||||
select(OrganizationDocumentModel)
|
|
||||||
.where(OrganizationDocumentModel.organization_id == organization_id)
|
|
||||||
.order_by(OrganizationDocumentModel.created_at.desc())
|
|
||||||
)
|
|
||||||
return [self._to_entity(m) for m in res.scalars().all()]
|
|
||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
|
from ulid import ULID
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
@@ -45,6 +46,16 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
|
|||||||
stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id)
|
stmt = stmt.where(PurchaseRequestModel.organization_id == organization_id)
|
||||||
return stmt
|
return stmt
|
||||||
|
|
||||||
|
async def create(self, values: dict[str, Any]) -> PurchaseRequestEntity:
|
||||||
|
model = PurchaseRequestModel(id=str(ULID()), **values)
|
||||||
|
self._session.add(model)
|
||||||
|
try:
|
||||||
|
await self._session.flush()
|
||||||
|
return self._to_entity(model)
|
||||||
|
except SQLAlchemyError as exc:
|
||||||
|
self._logger.exception(str(exc))
|
||||||
|
raise ApplicationException(status_code=500, message='Database error')
|
||||||
|
|
||||||
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
|
async def get_by_id(self, request_id: str) -> PurchaseRequestEntity:
|
||||||
res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id))
|
res = await self._session.execute(select(PurchaseRequestModel).where(PurchaseRequestModel.id == request_id))
|
||||||
m = res.scalar_one_or_none()
|
m = res.scalar_one_or_none()
|
||||||
@@ -79,3 +90,41 @@ class PurchaseRequestRepository(IPurchaseRequestRepository):
|
|||||||
)
|
)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
return await self.get_by_id(request_id)
|
return await self.get_by_id(request_id)
|
||||||
|
|
||||||
|
|
||||||
|
async def count_for_month(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
status: str | None,
|
||||||
|
organization_id: str | None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(PurchaseRequestModel)
|
||||||
|
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
|
||||||
|
stmt = stmt.where(
|
||||||
|
func.extract("year", PurchaseRequestModel.created_at) == year,
|
||||||
|
func.extract("month", PurchaseRequestModel.created_at) == month,
|
||||||
|
)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_for_day(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
day: int,
|
||||||
|
status: str | None,
|
||||||
|
organization_id: str | None
|
||||||
|
) -> int:
|
||||||
|
stmt = select(func.count()).select_from(PurchaseRequestModel)
|
||||||
|
stmt = self._apply_filters(stmt, status=status, organization_id=organization_id)
|
||||||
|
stmt = stmt.where(
|
||||||
|
func.extract("year", PurchaseRequestModel.created_at) == year,
|
||||||
|
func.extract("month", PurchaseRequestModel.created_at) == month,
|
||||||
|
func.extract("day", PurchaseRequestModel.created_at) == day,
|
||||||
|
)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|||||||
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, func, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
from src.infrastructure.database.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRepository(IRiskRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assessment(model: RiskAssessmentModel) -> RiskAssessmentEntity:
|
||||||
|
return RiskAssessmentEntity(
|
||||||
|
id=model.id,
|
||||||
|
user_id=model.user_id,
|
||||||
|
order_id=model.order_id,
|
||||||
|
subject_type=model.subject_type,
|
||||||
|
score=model.score,
|
||||||
|
decision=model.decision,
|
||||||
|
reasons=list(model.reasons or []),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event(model: AuditEventModel) -> AuditEventEntity:
|
||||||
|
return AuditEventEntity(
|
||||||
|
id=model.id,
|
||||||
|
actor_type=model.actor_type,
|
||||||
|
actor_id=model.actor_id,
|
||||||
|
action=model.action,
|
||||||
|
entity_type=model.entity_type,
|
||||||
|
entity_id=model.entity_id,
|
||||||
|
severity=model.severity,
|
||||||
|
metadata=dict(model.metadata_json or {}),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
stmt = select(RiskAssessmentModel).order_by(RiskAssessmentModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(RiskAssessmentModel)
|
||||||
|
filters = []
|
||||||
|
if decision:
|
||||||
|
filters.append(RiskAssessmentModel.decision == decision)
|
||||||
|
if user_id:
|
||||||
|
filters.append(RiskAssessmentModel.user_id == user_id)
|
||||||
|
if order_id:
|
||||||
|
filters.append(RiskAssessmentModel.order_id == order_id)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._assessment(row) for row in rows], total
|
||||||
|
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
stmt = select(
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "allow").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "manual_review").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "reject").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.avg(RiskAssessmentModel.score), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.score >= 60).cast(Integer)), 0),
|
||||||
|
)
|
||||||
|
row = (await self._session.execute(stmt)).one()
|
||||||
|
return RiskSummaryEntity(
|
||||||
|
total=int(row[0] or 0),
|
||||||
|
allow=int(row[1] or 0),
|
||||||
|
manual_review=int(row[2] or 0),
|
||||||
|
reject=int(row[3] or 0),
|
||||||
|
average_score=float(row[4] or 0),
|
||||||
|
high_risk=int(row[5] or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
stmt = select(AuditEventModel).order_by(AuditEventModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(AuditEventModel)
|
||||||
|
filters = []
|
||||||
|
if severity:
|
||||||
|
filters.append(AuditEventModel.severity == severity)
|
||||||
|
if action:
|
||||||
|
filters.append(AuditEventModel.action == action)
|
||||||
|
if entity_type:
|
||||||
|
filters.append(AuditEventModel.entity_type == entity_type)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._event(row) for row in rows], total
|
||||||
|
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
registrations = int((await self._session.execute(select(func.count()).select_from(UserModel))).scalar_one())
|
||||||
|
kyc_started = int((await self._session.execute(text("select count(distinct user_id) from kyc"))).scalar_one())
|
||||||
|
kyc_completed = int((await self._session.execute(select(func.count()).select_from(UserModel).where(UserModel.kyc_verified.is_(True)))).scalar_one())
|
||||||
|
first_payment = int((await self._session.execute(select(func.count(func.distinct(OrderModel.user_id))))).scalar_one())
|
||||||
|
successful_operations = int((await self._session.execute(text("select count(*) from payments where status = 'completed'"))).scalar_one())
|
||||||
|
return FunnelEntity(
|
||||||
|
registrations=registrations,
|
||||||
|
kyc_started=kyc_started,
|
||||||
|
kyc_completed=kyc_completed,
|
||||||
|
first_payment=first_payment,
|
||||||
|
successful_operations=successful_operations,
|
||||||
|
)
|
||||||
@@ -2,14 +2,14 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from fastapi import status
|
from sqlalchemy import func, or_, select
|
||||||
from sqlalchemy import select
|
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
||||||
|
|
||||||
from src.application.abstractions.repositories import IUserRepository
|
from src.application.abstractions.repositories import IUserRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities import UserEntity
|
from src.application.domain.entities import UserEntity
|
||||||
|
from src.application.domain.entities.party_search import PartySearchEntity
|
||||||
from src.application.domain.enums.account_type import AccountType
|
from src.application.domain.enums.account_type import AccountType
|
||||||
from src.application.domain.exceptions import InternalServerException, ApplicationException
|
from src.application.domain.exceptions import InternalServerException, ApplicationException
|
||||||
from src.infrastructure.database.models import UserModel
|
from src.infrastructure.database.models import UserModel
|
||||||
@@ -44,6 +44,16 @@ class UserRepository(IUserRepository):
|
|||||||
provisioned_by=user.provisioned_by,
|
provisioned_by=user.provisioned_by,
|
||||||
provisioned_at=user.provisioned_at,
|
provisioned_at=user.provisioned_at,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
async def get_by_id(self, user_id: str) -> UserEntity:
|
||||||
|
try:
|
||||||
|
user = await self._get_user(user_id)
|
||||||
|
return self._to_entity(user)
|
||||||
|
except ApplicationException:
|
||||||
|
raise
|
||||||
|
except SQLAlchemyError as exception:
|
||||||
|
self._logger.exception(str(exception))
|
||||||
|
raise InternalServerException(message=f'Database error: {str(exception)}')
|
||||||
|
|
||||||
async def create_legal_entity_user(
|
async def create_legal_entity_user(
|
||||||
self,
|
self,
|
||||||
@@ -73,10 +83,25 @@ class UserRepository(IUserRepository):
|
|||||||
except SQLAlchemyError as exc:
|
except SQLAlchemyError as exc:
|
||||||
self._logger.exception(str(exc))
|
self._logger.exception(str(exc))
|
||||||
raise ApplicationException(status_code=500, message='Database error')
|
raise ApplicationException(status_code=500, message='Database error')
|
||||||
|
|
||||||
|
async def _get_user(self, user_id: str) -> UserModel:
|
||||||
|
stmt = (
|
||||||
|
select(UserModel)
|
||||||
|
.where(
|
||||||
|
UserModel.id == user_id,
|
||||||
|
UserModel.is_deleted.is_(False),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
user: UserModel | None = result.scalar_one_or_none()
|
||||||
|
if user is None:
|
||||||
|
self._logger.warning(f'User not found with user_id {user_id}')
|
||||||
|
raise ApplicationException(status_code=404, message='User not found')
|
||||||
|
return user
|
||||||
|
|
||||||
async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
|
async def _update_field(self, user_id: str, **fields: object) -> UserEntity:
|
||||||
try:
|
try:
|
||||||
user = await self._get_active_user(user_id)
|
user = await self._get_user(user_id)
|
||||||
for key, value in fields.items():
|
for key, value in fields.items():
|
||||||
setattr(user, key, value)
|
setattr(user, key, value)
|
||||||
await self._session.flush()
|
await self._session.flush()
|
||||||
@@ -109,3 +134,119 @@ class UserRepository(IUserRepository):
|
|||||||
stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1)
|
stmt = select(UserModel.id).where(UserModel.email == email, UserModel.is_deleted.is_(False)).limit(1)
|
||||||
result = await self._session.execute(stmt)
|
result = await self._session.execute(stmt)
|
||||||
return result.scalar_one_or_none() is not None
|
return result.scalar_one_or_none() is not None
|
||||||
|
|
||||||
|
def _search_filter(self, search: str | None):
|
||||||
|
if not search or not search.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = f'%{search.strip()}%'
|
||||||
|
return or_(
|
||||||
|
UserModel.id.ilike(pattern),
|
||||||
|
UserModel.email.ilike(pattern),
|
||||||
|
UserModel.last_name.ilike(pattern),
|
||||||
|
UserModel.first_name.ilike(pattern),
|
||||||
|
UserModel.middle_name.ilike(pattern),
|
||||||
|
UserModel.phone.ilike(pattern),
|
||||||
|
UserModel.inn.ilike(pattern),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor_created_at=None,
|
||||||
|
) -> list[UserEntity]:
|
||||||
|
stmt = (
|
||||||
|
select(UserModel)
|
||||||
|
.where(UserModel.is_deleted.is_(False))
|
||||||
|
.order_by(UserModel.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(UserModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return [self._to_entity(m) for m in res.scalars().all()]
|
||||||
|
|
||||||
|
def _individual_search_filter(self, query: str):
|
||||||
|
pattern = f'%{query.strip()}%'
|
||||||
|
full_name = func.concat_ws(
|
||||||
|
' ',
|
||||||
|
UserModel.last_name,
|
||||||
|
UserModel.first_name,
|
||||||
|
UserModel.middle_name,
|
||||||
|
)
|
||||||
|
return or_(
|
||||||
|
UserModel.id.ilike(pattern),
|
||||||
|
UserModel.email.ilike(pattern),
|
||||||
|
UserModel.last_name.ilike(pattern),
|
||||||
|
UserModel.first_name.ilike(pattern),
|
||||||
|
UserModel.middle_name.ilike(pattern),
|
||||||
|
UserModel.phone.ilike(pattern),
|
||||||
|
UserModel.passport_data.ilike(pattern),
|
||||||
|
UserModel.inn.ilike(pattern),
|
||||||
|
UserModel.erc20.ilike(pattern),
|
||||||
|
full_name.ilike(pattern),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _to_search_entity(self, user: UserModel) -> PartySearchEntity:
|
||||||
|
name = ' '.join(
|
||||||
|
part for part in (user.last_name, user.first_name, user.middle_name)
|
||||||
|
if part
|
||||||
|
) or None
|
||||||
|
return PartySearchEntity(
|
||||||
|
id=user.id,
|
||||||
|
account_type=user.account_type,
|
||||||
|
user_id=user.id,
|
||||||
|
email=user.email,
|
||||||
|
name=name,
|
||||||
|
inn=user.inn,
|
||||||
|
phone=user.phone,
|
||||||
|
status=None,
|
||||||
|
kyc_verified=user.kyc_verified,
|
||||||
|
created_at=user.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def search_individuals(self, *, query: str, limit: int, offset: int) -> list[PartySearchEntity]:
|
||||||
|
stmt = (
|
||||||
|
select(UserModel)
|
||||||
|
.where(
|
||||||
|
UserModel.is_deleted.is_(False),
|
||||||
|
UserModel.account_type == AccountType.INDIVIDUAL,
|
||||||
|
self._individual_search_filter(query),
|
||||||
|
)
|
||||||
|
.order_by(UserModel.created_at.desc())
|
||||||
|
.limit(limit)
|
||||||
|
.offset(offset)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return [self._to_search_entity(user) for user in result.scalars().all()]
|
||||||
|
|
||||||
|
async def count_individuals(self, *, query: str) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count(UserModel.id))
|
||||||
|
.where(
|
||||||
|
UserModel.is_deleted.is_(False),
|
||||||
|
UserModel.account_type == AccountType.INDIVIDUAL,
|
||||||
|
self._individual_search_filter(query),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
result = await self._session.execute(stmt)
|
||||||
|
return int(result.scalar_one())
|
||||||
|
|
||||||
|
async def count_all(self, *, search: str | None = None) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count(UserModel.id))
|
||||||
|
.where(UserModel.is_deleted.is_(False))
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.application.abstractions.repositories import IWalletRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.user import WalletEntity
|
||||||
|
from src.infrastructure.database.models import WalletModel
|
||||||
|
|
||||||
|
|
||||||
|
class WalletRepository(IWalletRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
def _to_entity(self, m: WalletModel) -> WalletEntity:
|
||||||
|
return WalletEntity(
|
||||||
|
id=m.id,
|
||||||
|
user_id=m.user_id,
|
||||||
|
chain=m.chain,
|
||||||
|
address=m.address,
|
||||||
|
derivation_path=m.derivation_path,
|
||||||
|
created_at=m.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_by_user(self, user_id: str) -> list[WalletEntity]:
|
||||||
|
res = await self._session.execute(
|
||||||
|
select(WalletModel)
|
||||||
|
.where(WalletModel.user_id == user_id)
|
||||||
|
.order_by(WalletModel.chain)
|
||||||
|
)
|
||||||
|
return [self._to_entity(m) for m in res.scalars().all()]
|
||||||
|
|
||||||
|
async def exists_for_user(self, user_id: str) -> bool:
|
||||||
|
res = await self._session.execute(
|
||||||
|
select(WalletModel.id)
|
||||||
|
.where(WalletModel.user_id == user_id)
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
return res.scalar_one_or_none() is not None
|
||||||
@@ -5,10 +5,12 @@ from src.application.abstractions.repositories import (
|
|||||||
IAdminSessionRepository,
|
IAdminSessionRepository,
|
||||||
IAdminUserRepository,
|
IAdminUserRepository,
|
||||||
ILegalEntityRepository,
|
ILegalEntityRepository,
|
||||||
IOrganizationDocumentRepository,
|
|
||||||
IOrganizationWalletRepository,
|
IOrganizationWalletRepository,
|
||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
|
IWalletRepository,
|
||||||
|
IOrderRepository,
|
||||||
|
IRiskRepository,
|
||||||
)
|
)
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.exceptions import RefreshConcurrentException
|
from src.application.domain.exceptions import RefreshConcurrentException
|
||||||
@@ -16,10 +18,12 @@ from src.infrastructure.database.repositories import (
|
|||||||
AdminSessionRepository,
|
AdminSessionRepository,
|
||||||
AdminUserRepository,
|
AdminUserRepository,
|
||||||
LegalEntityRepository,
|
LegalEntityRepository,
|
||||||
OrganizationDocumentRepository,
|
|
||||||
OrganizationWalletRepository,
|
OrganizationWalletRepository,
|
||||||
PurchaseRequestRepository,
|
PurchaseRequestRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
|
WalletRepository,
|
||||||
|
OrderRepository,
|
||||||
|
RiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -32,12 +36,15 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
self._admin_session_repository: IAdminSessionRepository | None = None
|
self._admin_session_repository: IAdminSessionRepository | None = None
|
||||||
self._legal_entity_repository: ILegalEntityRepository | None = None
|
self._legal_entity_repository: ILegalEntityRepository | None = None
|
||||||
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
|
self._organization_wallet_repository: IOrganizationWalletRepository | None = None
|
||||||
self._organization_document_repository: IOrganizationDocumentRepository | None = None
|
|
||||||
self._purchase_request_repository: IPurchaseRequestRepository | None = None
|
self._purchase_request_repository: IPurchaseRequestRepository | None = None
|
||||||
|
self._wallet_repository: IWalletRepository | None = None
|
||||||
|
self._order_repository: IOrderRepository | None = None
|
||||||
|
self._risk_repository: IRiskRepository | None = None
|
||||||
self._logger: ILogger = logger
|
self._logger: ILogger = logger
|
||||||
|
|
||||||
async def __aenter__(self):
|
async def __aenter__(self):
|
||||||
self._session = self.session_factory()
|
self._session = self.session_factory()
|
||||||
|
self._risk_repository = None
|
||||||
return self
|
return self
|
||||||
|
|
||||||
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
@@ -87,14 +94,14 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
session=self._session, logger=self._logger
|
session=self._session, logger=self._logger
|
||||||
)
|
)
|
||||||
return self._organization_wallet_repository
|
return self._organization_wallet_repository
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def organization_document_repository(self) -> IOrganizationDocumentRepository:
|
def wallet_repository(self) -> IWalletRepository:
|
||||||
if self._organization_document_repository is None:
|
if self._wallet_repository is None:
|
||||||
self._organization_document_repository = OrganizationDocumentRepository(
|
self._wallet_repository = WalletRepository(
|
||||||
session=self._session, logger=self._logger
|
session=self._session, logger=self._logger
|
||||||
)
|
)
|
||||||
return self._organization_document_repository
|
return self._wallet_repository
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def purchase_request_repository(self) -> IPurchaseRequestRepository:
|
def purchase_request_repository(self) -> IPurchaseRequestRepository:
|
||||||
@@ -103,3 +110,17 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
session=self._session, logger=self._logger
|
session=self._session, logger=self._logger
|
||||||
)
|
)
|
||||||
return self._purchase_request_repository
|
return self._purchase_request_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_repository(self) -> IOrderRepository:
|
||||||
|
if self._order_repository is None:
|
||||||
|
self._order_repository = OrderRepository(
|
||||||
|
session=self._session, logger=self._logger
|
||||||
|
)
|
||||||
|
return self._order_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository:
|
||||||
|
if self._risk_repository is None:
|
||||||
|
self._risk_repository = RiskRepository(session=self._session, logger=self._logger)
|
||||||
|
return self._risk_repository
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ class HashService(IHashService):
|
|||||||
|
|
||||||
async def hash(self, value: str) -> str:
|
async def hash(self, value: str) -> str:
|
||||||
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
|
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
|
||||||
self._logger.info(f'Hash value {hashed_value.decode()}')
|
|
||||||
return hashed_value.decode()
|
return hashed_value.decode()
|
||||||
|
|
||||||
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
||||||
self._logger.info(f'Hash value {hashed_value[:10]}')
|
|
||||||
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())
|
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())
|
||||||
@@ -23,9 +23,13 @@ class S3DocumentsService:
|
|||||||
self._key_prefix = key_prefix.strip('/')
|
self._key_prefix = key_prefix.strip('/')
|
||||||
self._presigned_ttl_seconds = presigned_ttl_seconds
|
self._presigned_ttl_seconds = presigned_ttl_seconds
|
||||||
|
|
||||||
def build_object_key(self, organization_id: str, document_id: str, file_name: str) -> str:
|
@staticmethod
|
||||||
|
def file_name_from_key(key: str) -> str:
|
||||||
|
return key.rsplit('/', 1)[-1]
|
||||||
|
|
||||||
|
def build_object_key(self, organization_id: str, document_type: str, file_name: str) -> str:
|
||||||
safe_name = file_name.replace('/', '_').replace('\\', '_')
|
safe_name = file_name.replace('/', '_').replace('\\', '_')
|
||||||
return f'{self._key_prefix}/{organization_id}/{document_id}/{safe_name}'
|
return f'{self._key_prefix}/{organization_id}/{document_type}/{safe_name}'
|
||||||
|
|
||||||
def _client_kwargs(self) -> dict[str, object]:
|
def _client_kwargs(self) -> dict[str, object]:
|
||||||
kw: dict[str, object] = {'region_name': self._region}
|
kw: dict[str, object] = {'region_name': self._region}
|
||||||
|
|||||||
@@ -128,7 +128,7 @@ app: FastAPI = FastAPI(
|
|||||||
redoc_url=None,
|
redoc_url=None,
|
||||||
docs_url=None,
|
docs_url=None,
|
||||||
lifespan=lifespan,
|
lifespan=lifespan,
|
||||||
title='Bitforce. Admin Service',
|
title='Elcsa. Admin Service',
|
||||||
version='1.0.0',
|
version='1.0.0',
|
||||||
description='Admin API for legal entities, wallets, documents, and B2B purchase requests',
|
description='Admin API for legal entities, wallets, documents, and B2B purchase requests',
|
||||||
license_info={'name': 'MIT', 'url': 'https://opensource.org/licenses/MIT'},
|
license_info={'name': 'MIT', 'url': 'https://opensource.org/licenses/MIT'},
|
||||||
@@ -180,5 +180,11 @@ async def custom_redoc_html(_credentials: HTTPBasicCredentials = Depends(verify_
|
|||||||
async def ping() -> dict[str, str]:
|
async def ping() -> dict[str, str]:
|
||||||
return {'message': 'pong', 'status': 'ok'}
|
return {'message': 'pong', 'status': 'ok'}
|
||||||
|
|
||||||
|
@app.get('/healthcheck')
|
||||||
|
@app.get('/health')
|
||||||
|
async def health() -> dict[str, str]:
|
||||||
|
return {
|
||||||
|
'status': 'ok',
|
||||||
|
}
|
||||||
|
|
||||||
app.openapi = custom_openapi
|
app.openapi = custom_openapi
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ from src.application.commands import (
|
|||||||
AdminLoginCommand,
|
AdminLoginCommand,
|
||||||
AdminLogoutCommand,
|
AdminLogoutCommand,
|
||||||
AdminJwtRefreshCommand,
|
AdminJwtRefreshCommand,
|
||||||
|
CreatePurchaseRequestCommand,
|
||||||
GetAdminMeCommand,
|
GetAdminMeCommand,
|
||||||
CreateOrganizationCommand,
|
CreateOrganizationCommand,
|
||||||
CreateOrganizationWalletsCommand,
|
CreateOrganizationWalletsCommand,
|
||||||
@@ -25,6 +26,26 @@ from src.application.commands import (
|
|||||||
UpdatePurchaseRequestCommand,
|
UpdatePurchaseRequestCommand,
|
||||||
UpdatePurchaseRequestStatusCommand,
|
UpdatePurchaseRequestStatusCommand,
|
||||||
PutOrganizationDocumentCommand,
|
PutOrganizationDocumentCommand,
|
||||||
|
SetPasswordCommand,
|
||||||
|
ListUsersCommand,
|
||||||
|
GetUserCommand,
|
||||||
|
ListUserWalletsCommand,
|
||||||
|
GetUserMnemonicCommand,
|
||||||
|
GetUserSecretKeysCommand,
|
||||||
|
ListOrdersCommand,
|
||||||
|
ListUserOrdersCommand,
|
||||||
|
ListOrdersByDateCommand,
|
||||||
|
GetOrderCommand,
|
||||||
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
|
GetOrdersAnalyticsForYearCommand,
|
||||||
|
GetPurchaseAnalyticsForMonthCommand,
|
||||||
|
GetOrdersAnalyticsForMonthCommand,
|
||||||
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
|
ListRiskAssessmentsCommand,
|
||||||
|
GetRiskSummaryCommand,
|
||||||
|
ListAuditEventsCommand,
|
||||||
|
GetFunnelCommand,
|
||||||
)
|
)
|
||||||
from src.application.contracts import IHashService, IJwtService, ILogger
|
from src.application.contracts import IHashService, IJwtService, ILogger
|
||||||
from src.infrastructure.config import settings
|
from src.infrastructure.config import settings
|
||||||
@@ -173,6 +194,13 @@ def get_list_purchase_requests_command(
|
|||||||
return ListPurchaseRequestsCommand(uow, logger)
|
return ListPurchaseRequestsCommand(uow, logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_create_purchase_request_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> CreatePurchaseRequestCommand:
|
||||||
|
return CreatePurchaseRequestCommand(uow, logger)
|
||||||
|
|
||||||
|
|
||||||
def get_get_purchase_request_command(
|
def get_get_purchase_request_command(
|
||||||
uow: IUnitOfWork = Depends(get_unit_of_work),
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
@@ -199,3 +227,124 @@ def get_set_purchase_request_quote_command(
|
|||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
) -> SetPurchaseRequestQuoteCommand:
|
) -> SetPurchaseRequestQuoteCommand:
|
||||||
return SetPurchaseRequestQuoteCommand(uow, logger)
|
return SetPurchaseRequestQuoteCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_set_password_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
hash_service: IHashService = Depends(get_hash_service),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> SetPasswordCommand:
|
||||||
|
return SetPasswordCommand(uow, hash_service, logger)
|
||||||
|
|
||||||
|
def get_list_users_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListUsersCommand:
|
||||||
|
return ListUsersCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_user_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetUserCommand:
|
||||||
|
return GetUserCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_user_wallets_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListUserWalletsCommand:
|
||||||
|
return ListUserWalletsCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_user_mnemonic_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetUserMnemonicCommand:
|
||||||
|
return GetUserMnemonicCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_user_secret_keys_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetUserSecretKeysCommand:
|
||||||
|
return GetUserSecretKeysCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_orders_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListOrdersCommand:
|
||||||
|
return ListOrdersCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_user_orders_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListUserOrdersCommand:
|
||||||
|
return ListUserOrdersCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_orders_by_date_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListOrdersByDateCommand:
|
||||||
|
return ListOrdersByDateCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_order_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrderCommand:
|
||||||
|
return GetOrderCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_year_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForYearCommand:
|
||||||
|
return GetPurchaseAnalyticsForYearCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_year_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForYearCommand:
|
||||||
|
return GetOrdersAnalyticsForYearCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_month_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForMonthCommand:
|
||||||
|
return GetPurchaseAnalyticsForMonthCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_month_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForMonthCommand:
|
||||||
|
return GetOrdersAnalyticsForMonthCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_week_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForWeekCommand:
|
||||||
|
return GetPurchaseAnalyticsForWeekCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_week_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForWeekCommand:
|
||||||
|
return GetOrdersAnalyticsForWeekCommand(uow, logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_risk_assessments_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListRiskAssessmentsCommand:
|
||||||
|
return ListRiskAssessmentsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_risk_summary_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetRiskSummaryCommand:
|
||||||
|
return GetRiskSummaryCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_audit_events_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListAuditEventsCommand:
|
||||||
|
return ListAuditEventsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_funnel_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetFunnelCommand:
|
||||||
|
return GetFunnelCommand(uow)
|
||||||
@@ -6,6 +6,10 @@ from src.presentation.routing.jwt import jwt_router
|
|||||||
from src.presentation.routing.organizations import organizations_router
|
from src.presentation.routing.organizations import organizations_router
|
||||||
from src.presentation.routing.purchase_requests import purchase_requests_router
|
from src.presentation.routing.purchase_requests import purchase_requests_router
|
||||||
from src.presentation.routing.users import users_router
|
from src.presentation.routing.users import users_router
|
||||||
|
from src.presentation.routing.search import search_router
|
||||||
|
from src.presentation.routing.orders import orders_router
|
||||||
|
from src.presentation.routing.analytics import analytics_router
|
||||||
|
from src.presentation.routing.risk import audit_router, risk_router
|
||||||
|
|
||||||
v1_router = APIRouter(prefix='/v1')
|
v1_router = APIRouter(prefix='/v1')
|
||||||
v1_router.include_router(auth_router)
|
v1_router.include_router(auth_router)
|
||||||
@@ -13,4 +17,9 @@ v1_router.include_router(jwt_router)
|
|||||||
v1_router.include_router(organizations_router)
|
v1_router.include_router(organizations_router)
|
||||||
v1_router.include_router(documents_router)
|
v1_router.include_router(documents_router)
|
||||||
v1_router.include_router(purchase_requests_router)
|
v1_router.include_router(purchase_requests_router)
|
||||||
v1_router.include_router(users_router)
|
v1_router.include_router(users_router)
|
||||||
|
v1_router.include_router(search_router)
|
||||||
|
v1_router.include_router(orders_router)
|
||||||
|
v1_router.include_router(analytics_router)
|
||||||
|
v1_router.include_router(risk_router)
|
||||||
|
v1_router.include_router(audit_router)
|
||||||
172
src/presentation/routing/analytics.py
Normal file
172
src/presentation/routing/analytics.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, Query, status
|
||||||
|
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_get_purchase_analytics_for_year_command,
|
||||||
|
get_get_orders_analytics_for_year_command,
|
||||||
|
get_get_purchase_analytics_for_month_command,
|
||||||
|
get_get_orders_analytics_for_month_command,
|
||||||
|
get_get_purchase_analytics_for_week_command,
|
||||||
|
get_get_orders_analytics_for_week_command,
|
||||||
|
get_get_funnel_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.analytics import (
|
||||||
|
OrderAnalyticsResponse,
|
||||||
|
PurchaseRequestAnalyticsResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.risk import FunnelResponse
|
||||||
|
from src.application.commands.analytics_commands import (
|
||||||
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
|
GetOrdersAnalyticsForYearCommand,
|
||||||
|
GetPurchaseAnalyticsForMonthCommand,
|
||||||
|
GetOrdersAnalyticsForMonthCommand,
|
||||||
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.risk_commands import GetFunnelCommand
|
||||||
|
|
||||||
|
analytics_router = APIRouter(prefix='/analytics', tags=['analytics'])
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/yearly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_year(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForYearCommand = Depends(get_get_purchase_analytics_for_year_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/yearly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_year(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForYearCommand = Depends(get_get_orders_analytics_for_year_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/monthly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_month(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForMonthCommand = Depends(get_get_purchase_analytics_for_month_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/monthly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_month(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForMonthCommand = Depends(get_get_orders_analytics_for_month_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/weekly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_week(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForWeekCommand = Depends(get_get_purchase_analytics_for_week_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/weekly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_week(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForWeekCommand = Depends(get_get_orders_analytics_for_week_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_router.get('/funnel', response_model=FunnelResponse)
|
||||||
|
async def get_funnel(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetFunnelCommand = Depends(get_get_funnel_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return FunnelResponse(**item.__dict__)
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
from fastapi import APIRouter, Depends, File, Form, UploadFile, status
|
from fastapi import APIRouter, Depends, File, UploadFile
|
||||||
|
|
||||||
from src.application.commands import (
|
from src.application.commands import (
|
||||||
GetOrganizationDocumentCommand,
|
GetOrganizationDocumentCommand,
|
||||||
ListOrganizationDocumentsCommand,
|
ListOrganizationDocumentsCommand,
|
||||||
UploadOrganizationDocumentCommand,
|
PutOrganizationDocumentCommand,
|
||||||
)
|
)
|
||||||
from src.application.domain.dto import AdminAuthContext
|
from src.application.domain.dto import AdminAuthContext
|
||||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||||
from src.presentation.dependencies.commands import (
|
from src.presentation.dependencies.commands import (
|
||||||
get_get_organization_document_command,
|
get_get_organization_document_command,
|
||||||
get_list_organization_documents_command,
|
get_list_organization_documents_command,
|
||||||
|
get_put_organization_document_command,
|
||||||
get_s3_documents_service,
|
get_s3_documents_service,
|
||||||
get_upload_organization_document_command,
|
|
||||||
)
|
)
|
||||||
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
from src.infrastructure.storage.s3_documents_service import S3DocumentsService
|
||||||
from src.presentation.schemas.mappers import document_to_response
|
from src.presentation.schemas.mappers import document_to_response
|
||||||
@@ -20,6 +20,12 @@ from src.presentation.schemas.organization import DocumentResponse
|
|||||||
documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents'])
|
documents_router = APIRouter(prefix='/organizations/{organization_id}/documents', tags=['documents'])
|
||||||
|
|
||||||
|
|
||||||
|
async def _document_download_url(s3: S3DocumentsService, s3_key: str | None) -> str | None:
|
||||||
|
if not s3_key:
|
||||||
|
return None
|
||||||
|
return await s3.generate_presigned_download_url(key=s3_key)
|
||||||
|
|
||||||
|
|
||||||
@documents_router.get('', response_model=list[DocumentResponse])
|
@documents_router.get('', response_model=list[DocumentResponse])
|
||||||
async def list_documents(
|
async def list_documents(
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
@@ -30,41 +36,226 @@ async def list_documents(
|
|||||||
docs = await command(organization_id)
|
docs = await command(organization_id)
|
||||||
result: list[DocumentResponse] = []
|
result: list[DocumentResponse] = []
|
||||||
for doc in docs:
|
for doc in docs:
|
||||||
url = await s3.generate_presigned_download_url(key=doc.s3_key)
|
url = await _document_download_url(s3, doc.s3_key)
|
||||||
result.append(document_to_response(doc, download_url=url))
|
result.append(document_to_response(doc, download_url=url))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
@documents_router.post('', response_model=DocumentResponse, status_code=status.HTTP_201_CREATED)
|
async def _put_document(
|
||||||
async def upload_document(
|
*,
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
document_type: str = Form(...),
|
document_type: str,
|
||||||
file: UploadFile = File(...),
|
file: UploadFile,
|
||||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
command: PutOrganizationDocumentCommand,
|
||||||
command: UploadOrganizationDocumentCommand = Depends(get_upload_organization_document_command),
|
s3: S3DocumentsService,
|
||||||
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
) -> DocumentResponse:
|
||||||
):
|
|
||||||
body = await file.read()
|
body = await file.read()
|
||||||
saved = await command(
|
saved = await command(
|
||||||
organization_id=organization_id,
|
organization_id=organization_id,
|
||||||
admin_user_id=auth.admin_user_id,
|
|
||||||
document_type=document_type,
|
document_type=document_type,
|
||||||
file_name=file.filename or 'document',
|
file_name=file.filename or 'document',
|
||||||
content_type=file.content_type or 'application/octet-stream',
|
content_type=file.content_type or 'application/octet-stream',
|
||||||
body=body,
|
body=body,
|
||||||
)
|
)
|
||||||
url = await s3.generate_presigned_download_url(key=saved.s3_key)
|
url = await _document_download_url(s3, saved.s3_key)
|
||||||
return document_to_response(saved, download_url=url)
|
return document_to_response(saved, download_url=url)
|
||||||
|
|
||||||
|
|
||||||
@documents_router.get('/{document_id}', response_model=DocumentResponse)
|
@documents_router.put('/charter', response_model=DocumentResponse)
|
||||||
async def get_document(
|
async def put_charter_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='charter',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/inn-certificate', response_model=DocumentResponse)
|
||||||
|
async def put_inn_certificate_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='inn_certificate',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/ogrn-certificate', response_model=DocumentResponse)
|
||||||
|
async def put_ogrn_certificate_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='ogrn_certificate',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/bank-details', response_model=DocumentResponse)
|
||||||
|
async def put_bank_details_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='bank_details',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/kyc-representative', response_model=DocumentResponse)
|
||||||
|
async def put_kyc_representative_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='kyc_representative',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/power-of-attorney', response_model=DocumentResponse)
|
||||||
|
async def put_power_of_attorney_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='power_of_attorney',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.put('/other', response_model=DocumentResponse)
|
||||||
|
async def put_other_document(
|
||||||
|
organization_id: str,
|
||||||
|
file: UploadFile = File(...),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: PutOrganizationDocumentCommand = Depends(get_put_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _put_document(
|
||||||
|
organization_id=organization_id,
|
||||||
|
document_type='other',
|
||||||
|
file=file,
|
||||||
|
command=command,
|
||||||
|
s3=s3,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/charter', response_model=DocumentResponse)
|
||||||
|
async def get_charter_document(
|
||||||
organization_id: str,
|
organization_id: str,
|
||||||
document_id: str,
|
|
||||||
auth: AdminAuthContext = Depends(require_admin_access),
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
):
|
):
|
||||||
doc = await command(organization_id, document_id)
|
return await _get_document(organization_id, 'charter', command, s3)
|
||||||
url = await s3.generate_presigned_download_url(key=doc.s3_key)
|
|
||||||
|
|
||||||
|
@documents_router.get('/inn-certificate', response_model=DocumentResponse)
|
||||||
|
async def get_inn_certificate_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'inn_certificate', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/ogrn-certificate', response_model=DocumentResponse)
|
||||||
|
async def get_ogrn_certificate_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'ogrn_certificate', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/bank-details', response_model=DocumentResponse)
|
||||||
|
async def get_bank_details_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'bank_details', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/kyc-representative', response_model=DocumentResponse)
|
||||||
|
async def get_kyc_representative_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'kyc_representative', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/power-of-attorney', response_model=DocumentResponse)
|
||||||
|
async def get_power_of_attorney_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'power_of_attorney', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
@documents_router.get('/other', response_model=DocumentResponse)
|
||||||
|
async def get_other_document(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrganizationDocumentCommand = Depends(get_get_organization_document_command),
|
||||||
|
s3: S3DocumentsService = Depends(get_s3_documents_service),
|
||||||
|
):
|
||||||
|
return await _get_document(organization_id, 'other', command, s3)
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_document(
|
||||||
|
organization_id: str,
|
||||||
|
document_type: str,
|
||||||
|
command: GetOrganizationDocumentCommand,
|
||||||
|
s3: S3DocumentsService,
|
||||||
|
) -> DocumentResponse:
|
||||||
|
doc = await command(organization_id, document_type)
|
||||||
|
url = await _document_download_url(s3, doc.s3_key)
|
||||||
return document_to_response(doc, download_url=url)
|
return document_to_response(doc, download_url=url)
|
||||||
|
|||||||
90
src/presentation/routing/orders.py
Normal file
90
src/presentation/routing/orders.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_list_orders_command,
|
||||||
|
get_list_orders_by_date_command,
|
||||||
|
get_get_order_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.mappers import order_to_response
|
||||||
|
from src.presentation.schemas.order import (
|
||||||
|
OrdersResponse,
|
||||||
|
OrdersByDateResponse,
|
||||||
|
OrderDetailResponse,
|
||||||
|
)
|
||||||
|
from src.application.commands.orders_commands import (
|
||||||
|
ListOrdersCommand,
|
||||||
|
ListOrdersByDateCommand,
|
||||||
|
GetOrderCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
orders_router = APIRouter(prefix='/orders', tags=['orders'])
|
||||||
|
|
||||||
|
@orders_router.get('', response_model=OrdersResponse)
|
||||||
|
async def list_orders(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListOrdersCommand = Depends(get_list_orders_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
|
||||||
|
return OrdersResponse(
|
||||||
|
items=[order_to_response(x) for x in items],
|
||||||
|
total=total,
|
||||||
|
next_cursor=items[-1].created_at.isoformat() if items and items[-1].created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@orders_router.get('/{order_id}', response_model=OrderDetailResponse)
|
||||||
|
async def get_order(
|
||||||
|
order_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrderCommand = Depends(get_get_order_command),
|
||||||
|
):
|
||||||
|
order = await command(order_id=order_id)
|
||||||
|
return OrderDetailResponse(
|
||||||
|
id=order.id,
|
||||||
|
created_at=order.created_at.isoformat() if order.created_at is not None else None,
|
||||||
|
updated_at=order.updated_at.isoformat() if order.updated_at is not None else None,
|
||||||
|
user_id=order.user_id,
|
||||||
|
usdt_amount=str(order.usdt_amount) if order.usdt_amount is not None else None,
|
||||||
|
usdt_exchange_rate=str(order.usdt_exchange_rate) if order.usdt_exchange_rate is not None else None,
|
||||||
|
gas_fee=str(order.gas_fee) if order.gas_fee is not None else None,
|
||||||
|
total_price=str(order.total_price) if order.total_price is not None else None,
|
||||||
|
service_fee=str(order.service_fee) if order.service_fee is not None else None,
|
||||||
|
status=order.status,
|
||||||
|
client_payment_id=order.client_payment_id,
|
||||||
|
itpay_payment_qr_url_desktop=order.itpay_payment_qr_url_desktop,
|
||||||
|
itpay_payment_qr_url_android=order.itpay_payment_qr_url_android,
|
||||||
|
itpay_payment_qr_url_ios=order.itpay_payment_qr_url_ios,
|
||||||
|
itpay_payment_qr_image_desktop=order.itpay_payment_qr_image_desktop,
|
||||||
|
itpay_payment_qr_image_android=order.itpay_payment_qr_image_android,
|
||||||
|
itpay_payment_qr_image_ios=order.itpay_payment_qr_image_ios,
|
||||||
|
itpay_id=order.itpay_id,
|
||||||
|
itpay_qr_id=order.itpay_qr_id,
|
||||||
|
itpay_amount=str(order.itpay_amount) if order.itpay_amount is not None else None,
|
||||||
|
itpay_created_at=order.itpay_created_at.isoformat() if order.itpay_created_at is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@orders_router.get('/filter/date', response_model=OrdersByDateResponse)
|
||||||
|
async def list_orders_by_date(
|
||||||
|
year: int = Query(..., ge=2000, le=2100),
|
||||||
|
month: int = Query(..., ge=1, le=12),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListOrdersByDateCommand = Depends(get_list_orders_by_date_command),
|
||||||
|
):
|
||||||
|
orders, total, summary = await command(year=year, month=month, limit=limit, offset=offset, cursor=cursor)
|
||||||
|
return OrdersByDateResponse(
|
||||||
|
items=[order_to_response(o) for o in orders],
|
||||||
|
total=total,
|
||||||
|
summed_service_fees=summary,
|
||||||
|
next_cursor=orders[-1].created_at.isoformat() if orders and orders[-1].created_at else None,
|
||||||
|
)
|
||||||
@@ -4,6 +4,9 @@ from src.application.commands import (
|
|||||||
CreateOrganizationCommand,
|
CreateOrganizationCommand,
|
||||||
CreateOrganizationWalletsCommand,
|
CreateOrganizationWalletsCommand,
|
||||||
GetOrganizationCommand,
|
GetOrganizationCommand,
|
||||||
|
GetOrganizationMnemonicCommand,
|
||||||
|
GetOrganizationSecretKeysCommand,
|
||||||
|
ListOrganizationWalletsCommand,
|
||||||
ListOrganizationsCommand,
|
ListOrganizationsCommand,
|
||||||
UpdateOrganizationCommand,
|
UpdateOrganizationCommand,
|
||||||
)
|
)
|
||||||
@@ -13,15 +16,29 @@ from src.presentation.dependencies.commands import (
|
|||||||
get_create_organization_command,
|
get_create_organization_command,
|
||||||
get_create_organization_wallets_command,
|
get_create_organization_wallets_command,
|
||||||
get_get_organization_command,
|
get_get_organization_command,
|
||||||
|
get_get_organization_mnemonic_command,
|
||||||
|
get_get_organization_secret_keys_command,
|
||||||
|
get_list_organization_wallets_command,
|
||||||
get_list_organizations_command,
|
get_list_organizations_command,
|
||||||
get_update_organization_command,
|
get_update_organization_command,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.mappers import organization_to_response, wallet_to_response
|
from src.presentation.schemas.mappers import (
|
||||||
|
create_wallets_to_response,
|
||||||
|
mnemonic_to_response,
|
||||||
|
organization_to_response,
|
||||||
|
secret_key_to_response,
|
||||||
|
wallet_to_response,
|
||||||
|
)
|
||||||
from src.presentation.schemas.organization import (
|
from src.presentation.schemas.organization import (
|
||||||
CreateOrganizationRequest,
|
CreateOrganizationRequest,
|
||||||
OrganizationListResponse,
|
OrganizationListResponse,
|
||||||
OrganizationResponse,
|
OrganizationResponse,
|
||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.entity import (
|
||||||
|
CreateWalletsResponse,
|
||||||
|
MnemonicResponse,
|
||||||
|
SecretKeyResponse,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -32,10 +49,11 @@ organizations_router = APIRouter(prefix='/organizations', tags=['organizations']
|
|||||||
async def list_organizations(
|
async def list_organizations(
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
auth: AdminAuthContext = Depends(require_admin_access),
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
|
command: ListOrganizationsCommand = Depends(get_list_organizations_command),
|
||||||
):
|
):
|
||||||
items, total = await command(limit=limit, offset=offset)
|
items, total = await command(limit=limit, offset=offset, search=q)
|
||||||
return OrganizationListResponse(
|
return OrganizationListResponse(
|
||||||
items=[organization_to_response(x) for x in items],
|
items=[organization_to_response(x) for x in items],
|
||||||
total=total,
|
total=total,
|
||||||
@@ -88,9 +106,47 @@ async def update_organization(
|
|||||||
return organization_to_response(org)
|
return organization_to_response(org)
|
||||||
|
|
||||||
|
|
||||||
|
@organizations_router.get('/{organization_id}/wallets', response_model=list[WalletResponse])
|
||||||
|
async def list_organization_wallets(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListOrganizationWalletsCommand = Depends(get_list_organization_wallets_command),
|
||||||
|
):
|
||||||
|
wallets = await command(organization_id)
|
||||||
|
return [wallet_to_response(w) for w in wallets]
|
||||||
|
|
||||||
|
|
||||||
|
@organizations_router.get('/{organization_id}/wallets/mnemonic', response_model=MnemonicResponse)
|
||||||
|
async def get_organization_mnemonic(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: GetOrganizationMnemonicCommand = Depends(get_get_organization_mnemonic_command),
|
||||||
|
):
|
||||||
|
mnemonic = await command(organization_id)
|
||||||
|
return mnemonic_to_response(mnemonic)
|
||||||
|
|
||||||
|
|
||||||
|
@organizations_router.get('/{organization_id}/wallets/secret-keys', response_model=list[SecretKeyResponse])
|
||||||
|
async def get_organization_secret_keys(
|
||||||
|
organization_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: GetOrganizationSecretKeysCommand = Depends(get_get_organization_secret_keys_command),
|
||||||
|
):
|
||||||
|
keys = await command(organization_id)
|
||||||
|
return [
|
||||||
|
secret_key_to_response(
|
||||||
|
chain=k.chain,
|
||||||
|
address=k.address,
|
||||||
|
derivation_path=k.derivation_path,
|
||||||
|
private_key=k.private_key,
|
||||||
|
)
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
@organizations_router.post(
|
@organizations_router.post(
|
||||||
'/{organization_id}/wallets/create',
|
'/{organization_id}/wallets/create',
|
||||||
response_model=list[WalletResponse],
|
response_model=CreateWalletsResponse,
|
||||||
status_code=status.HTTP_201_CREATED,
|
status_code=status.HTTP_201_CREATED,
|
||||||
)
|
)
|
||||||
async def create_organization_wallets(
|
async def create_organization_wallets(
|
||||||
@@ -98,5 +154,5 @@ async def create_organization_wallets(
|
|||||||
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command),
|
command: CreateOrganizationWalletsCommand = Depends(get_create_organization_wallets_command),
|
||||||
):
|
):
|
||||||
wallets = await command(organization_id=organization_id)
|
result = await command(organization_id=organization_id)
|
||||||
return [wallet_to_response(w) for w in wallets]
|
return create_wallets_to_response(wallets=result.wallets, mnemonic=result.mnemonic)
|
||||||
|
|||||||
@@ -1,24 +1,30 @@
|
|||||||
from fastapi import APIRouter, Depends, Query, status
|
from fastapi import APIRouter, Depends, Query, status
|
||||||
|
|
||||||
from src.application.commands import (
|
from src.application.commands import (
|
||||||
|
CreatePurchaseRequestCommand,
|
||||||
GetPurchaseRequestCommand,
|
GetPurchaseRequestCommand,
|
||||||
ListPurchaseRequestsCommand,
|
ListPurchaseRequestsCommand,
|
||||||
SetPurchaseRequestQuoteCommand,
|
SetPurchaseRequestQuoteCommand,
|
||||||
|
UpdatePurchaseRequestCommand,
|
||||||
UpdatePurchaseRequestStatusCommand,
|
UpdatePurchaseRequestStatusCommand,
|
||||||
)
|
)
|
||||||
from src.application.domain.dto import AdminAuthContext
|
from src.application.domain.dto import AdminAuthContext
|
||||||
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||||
from src.presentation.dependencies.commands import (
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_create_purchase_request_command,
|
||||||
get_get_purchase_request_command,
|
get_get_purchase_request_command,
|
||||||
get_list_purchase_requests_command,
|
get_list_purchase_requests_command,
|
||||||
get_set_purchase_request_quote_command,
|
get_set_purchase_request_quote_command,
|
||||||
|
get_update_purchase_request_command,
|
||||||
get_update_purchase_request_status_command,
|
get_update_purchase_request_status_command,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.mappers import purchase_request_to_response
|
from src.presentation.schemas.mappers import purchase_request_to_response
|
||||||
from src.presentation.schemas.organization import (
|
from src.presentation.schemas.entity import (
|
||||||
|
CreatePurchaseRequestBody,
|
||||||
PurchaseRequestListResponse,
|
PurchaseRequestListResponse,
|
||||||
PurchaseRequestResponse,
|
PurchaseRequestResponse,
|
||||||
SetPurchaseRequestQuoteBody,
|
SetPurchaseRequestQuoteBody,
|
||||||
|
UpdatePurchaseRequestBody,
|
||||||
UpdatePurchaseRequestStatusBody,
|
UpdatePurchaseRequestStatusBody,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -46,6 +52,29 @@ async def list_purchase_requests(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_requests_router.post('', response_model=PurchaseRequestResponse, status_code=status.HTTP_201_CREATED)
|
||||||
|
async def create_purchase_request(
|
||||||
|
body: CreatePurchaseRequestBody,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: CreatePurchaseRequestCommand = Depends(get_create_purchase_request_command),
|
||||||
|
):
|
||||||
|
item = await command(
|
||||||
|
organization_id=body.organization_id,
|
||||||
|
rub_amount=body.rub_amount,
|
||||||
|
status=body.status,
|
||||||
|
usdt_amount=body.usdt_amount,
|
||||||
|
exchange_rate=body.exchange_rate,
|
||||||
|
service_fee_percent=body.service_fee_percent,
|
||||||
|
comment=body.comment,
|
||||||
|
admin_comment=body.admin_comment,
|
||||||
|
target_wallet_chain=body.target_wallet_chain,
|
||||||
|
target_wallet_address=body.target_wallet_address,
|
||||||
|
tx_hash=body.tx_hash,
|
||||||
|
assigned_to=body.assigned_to,
|
||||||
|
)
|
||||||
|
return purchase_request_to_response(item)
|
||||||
|
|
||||||
|
|
||||||
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
|
@purchase_requests_router.get('/{request_id}', response_model=PurchaseRequestResponse)
|
||||||
async def get_purchase_request(
|
async def get_purchase_request(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
@@ -56,6 +85,17 @@ async def get_purchase_request(
|
|||||||
return purchase_request_to_response(item)
|
return purchase_request_to_response(item)
|
||||||
|
|
||||||
|
|
||||||
|
@purchase_requests_router.patch('/{request_id}', response_model=PurchaseRequestResponse)
|
||||||
|
async def update_purchase_request(
|
||||||
|
request_id: str,
|
||||||
|
body: UpdatePurchaseRequestBody,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: UpdatePurchaseRequestCommand = Depends(get_update_purchase_request_command),
|
||||||
|
):
|
||||||
|
item = await command(request_id, values=body.model_dump(exclude_unset=True))
|
||||||
|
return purchase_request_to_response(item)
|
||||||
|
|
||||||
|
|
||||||
@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse)
|
@purchase_requests_router.patch('/{request_id}/status', response_model=PurchaseRequestResponse)
|
||||||
async def update_purchase_request_status(
|
async def update_purchase_request_status(
|
||||||
request_id: str,
|
request_id: str,
|
||||||
|
|||||||
80
src/presentation/routing/risk.py
Normal file
80
src/presentation/routing/risk.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.commands.risk_commands import GetRiskSummaryCommand, ListAuditEventsCommand, ListRiskAssessmentsCommand
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import get_get_risk_summary_command, get_list_audit_events_command, get_list_risk_assessments_command
|
||||||
|
from src.presentation.schemas.risk import AuditEventResponse, AuditEventsResponse, RiskAssessmentResponse, RiskAssessmentsResponse, RiskSummaryResponse
|
||||||
|
|
||||||
|
risk_router = APIRouter(prefix="/risk", tags=["risk"])
|
||||||
|
audit_router = APIRouter(prefix="/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/assessments", response_model=RiskAssessmentsResponse)
|
||||||
|
async def list_risk_assessments(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
decision: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
user_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
order_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListRiskAssessmentsCommand = Depends(get_list_risk_assessments_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
return RiskAssessmentsResponse(
|
||||||
|
items=[
|
||||||
|
RiskAssessmentResponse(
|
||||||
|
id=x.id,
|
||||||
|
user_id=x.user_id,
|
||||||
|
order_id=x.order_id,
|
||||||
|
subject_type=x.subject_type,
|
||||||
|
score=x.score,
|
||||||
|
decision=x.decision,
|
||||||
|
reasons=x.reasons,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/summary", response_model=RiskSummaryResponse)
|
||||||
|
async def get_risk_summary(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetRiskSummaryCommand = Depends(get_get_risk_summary_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return RiskSummaryResponse(**item.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
@audit_router.get("/events", response_model=AuditEventsResponse)
|
||||||
|
async def list_audit_events(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
severity: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
action: str | None = Query(default=None, min_length=1, max_length=128),
|
||||||
|
entity_type: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListAuditEventsCommand = Depends(get_list_audit_events_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
return AuditEventsResponse(
|
||||||
|
items=[
|
||||||
|
AuditEventResponse(
|
||||||
|
id=x.id,
|
||||||
|
actor_type=x.actor_type,
|
||||||
|
actor_id=x.actor_id,
|
||||||
|
action=x.action,
|
||||||
|
entity_type=x.entity_type,
|
||||||
|
entity_id=x.entity_id,
|
||||||
|
severity=x.severity,
|
||||||
|
metadata=x.metadata,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
32
src/presentation/routing/search.py
Normal file
32
src/presentation/routing/search.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.commands import (
|
||||||
|
SearchPartiesCommand,
|
||||||
|
)
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_search_parties_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.mappers import (
|
||||||
|
party_search_to_response,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.entity import (
|
||||||
|
PartySearchListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
search_router = APIRouter(prefix='/search', tags=['search'])
|
||||||
|
|
||||||
|
@search_router.get('', response_model=PartySearchListResponse)
|
||||||
|
async def search_parties(
|
||||||
|
q: str = Query(min_length=1, max_length=255),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: SearchPartiesCommand = Depends(get_search_parties_command),
|
||||||
|
):
|
||||||
|
items, total = await command(query=q, limit=limit, offset=offset)
|
||||||
|
return PartySearchListResponse(
|
||||||
|
items=[party_search_to_response(x) for x in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
@@ -1,17 +1,153 @@
|
|||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, Request, Query, status
|
||||||
from fastapi.responses import ORJSONResponse
|
from fastapi.responses import ORJSONResponse
|
||||||
from starlette import status
|
from starlette import status
|
||||||
from src.presentation.dependencies.commands import get_set_password_command
|
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_set_password_command,
|
||||||
|
get_list_users_command,
|
||||||
|
get_get_user_command,
|
||||||
|
get_list_user_wallets_command,
|
||||||
|
get_get_user_mnemonic_command,
|
||||||
|
get_get_user_secret_keys_command,
|
||||||
|
get_list_user_orders_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.mappers import (
|
||||||
|
mnemonic_to_response,
|
||||||
|
user_to_response,
|
||||||
|
secret_key_to_response,
|
||||||
|
wallet_to_response,
|
||||||
|
order_to_response,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.user import (
|
||||||
|
UserResponse,
|
||||||
|
UserListResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.entity import (
|
||||||
|
MnemonicResponse,
|
||||||
|
SecretKeyResponse,
|
||||||
|
WalletResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.order import (
|
||||||
|
OrdersResponse,
|
||||||
|
)
|
||||||
from src.application.commands.set_password import SetPasswordCommand
|
from src.application.commands.set_password import SetPasswordCommand
|
||||||
from presentation.schemas.password import SetPasswordRequest
|
from src.application.commands.user_commands import (
|
||||||
|
ListUsersCommand,
|
||||||
|
GetUserCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.user_wallet_commands import (
|
||||||
|
ListUserWalletsCommand,
|
||||||
|
GetUserMnemonicCommand,
|
||||||
|
GetUserSecretKeysCommand
|
||||||
|
)
|
||||||
|
from src.application.commands.orders_commands import (
|
||||||
|
ListUserOrdersCommand,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.password import SetPasswordRequest
|
||||||
|
|
||||||
|
|
||||||
users_router = APIRouter(prefix='/users', tags=['users'])
|
users_router = APIRouter(prefix='/users', tags=['users'])
|
||||||
|
|
||||||
@users_router.patch(path='/password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
@users_router.get('', response_model=UserListResponse)
|
||||||
async def set_password(
|
async def list_users(
|
||||||
request: Request,
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListUsersCommand = Depends(get_list_users_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
|
||||||
|
return UserListResponse(
|
||||||
|
items=[user_to_response(x) for x in items],
|
||||||
|
total=total,
|
||||||
|
next_cursor=items[-1].created_at.isoformat() if items and items[-1].created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
@users_router.get('/{user_id}', response_model=UserResponse)
|
||||||
|
async def get_user(
|
||||||
|
user_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetUserCommand = Depends(get_get_user_command),
|
||||||
|
):
|
||||||
|
user = await command(user_id)
|
||||||
|
return user_to_response(user)
|
||||||
|
|
||||||
|
@users_router.get('/{user_id}/wallets', response_model=list[WalletResponse])
|
||||||
|
async def list_user_wallets(
|
||||||
|
user_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListUserWalletsCommand = Depends(get_list_user_wallets_command),
|
||||||
|
):
|
||||||
|
wallets = await command(user_id)
|
||||||
|
return [wallet_to_response(w) for w in wallets]
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.get('/{user_id}/orders', response_model=OrdersResponse)
|
||||||
|
async def list_user_orders(
|
||||||
|
user_id: str,
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListUserOrdersCommand = Depends(get_list_user_orders_command),
|
||||||
|
):
|
||||||
|
orders, total = await command(user_id=user_id, limit=limit, offset=offset, cursor=cursor)
|
||||||
|
return OrdersResponse(
|
||||||
|
items=[order_to_response(o) for o in orders],
|
||||||
|
total=total,
|
||||||
|
next_cursor=orders[-1].created_at.isoformat() if orders and orders[-1].created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.get('/{user_id}/wallets/mnemonic', response_model=MnemonicResponse)
|
||||||
|
async def get_user_mnemonic(
|
||||||
|
user_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: GetUserMnemonicCommand = Depends(get_get_user_mnemonic_command),
|
||||||
|
):
|
||||||
|
mnemonic = await command(user_id)
|
||||||
|
return mnemonic_to_response(mnemonic)
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.get('/{user_id}/wallets/secret-keys', response_model=list[SecretKeyResponse])
|
||||||
|
async def get_user_secret_keys(
|
||||||
|
user_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: GetUserSecretKeysCommand = Depends(get_get_user_secret_keys_command),
|
||||||
|
):
|
||||||
|
keys = await command(user_id)
|
||||||
|
return [
|
||||||
|
secret_key_to_response(
|
||||||
|
chain=k.chain,
|
||||||
|
address=k.address,
|
||||||
|
derivation_path=k.derivation_path,
|
||||||
|
private_key=k.private_key,
|
||||||
|
)
|
||||||
|
for k in keys
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.patch(path='/change_password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def change_password(
|
||||||
|
request: Request,
|
||||||
body: SetPasswordRequest,
|
body: SetPasswordRequest,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
command: SetPasswordCommand = Depends(get_set_password_command),
|
command: SetPasswordCommand = Depends(get_set_password_command),
|
||||||
):
|
):
|
||||||
await command(email=body.email, password=body.password)
|
await command(email=body.email, password=body.password)
|
||||||
return ORJSONResponse(content={'message': 'Password updated successfully'})
|
return ORJSONResponse(content={'message': 'Password updated successfully'})
|
||||||
|
|
||||||
|
|
||||||
|
@users_router.patch(path='/{user_id}/set_password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||||
|
async def set_password(
|
||||||
|
user_id: str,
|
||||||
|
request: Request,
|
||||||
|
body: SetPasswordRequest,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: SetPasswordCommand = Depends(get_set_password_command),
|
||||||
|
):
|
||||||
|
await command(user_id=user_id, password=body.password)
|
||||||
|
return ORJSONResponse(content={'message': 'Password updated successfully'})
|
||||||
|
|||||||
15
src/presentation/schemas/analytics.py
Normal file
15
src/presentation/schemas/analytics.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class OrderAnalyticsResponse(BaseModel):
|
||||||
|
orders_count: list[int]
|
||||||
|
summed_service_fees: list[Decimal]
|
||||||
|
total_orders: int
|
||||||
|
total_summed_service_fees: Decimal
|
||||||
|
period: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequestAnalyticsResponse(BaseModel):
|
||||||
|
requests_count: list[int]
|
||||||
|
total_requests: int
|
||||||
|
period: int | None = None
|
||||||
114
src/presentation/schemas/entity.py
Normal file
114
src/presentation/schemas/entity.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from decimal import Decimal
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class PartySearchResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
account_type: str
|
||||||
|
user_id: str
|
||||||
|
email: str | None
|
||||||
|
name: str | None
|
||||||
|
inn: str | None
|
||||||
|
phone: str | None
|
||||||
|
status: str | None
|
||||||
|
kyc_verified: bool | None
|
||||||
|
created_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PartySearchListResponse(BaseModel):
|
||||||
|
items: list[PartySearchResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class WalletResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
derivation_path: str
|
||||||
|
created_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class CreateWalletsResponse(BaseModel):
|
||||||
|
wallets: list[WalletResponse]
|
||||||
|
mnemonic: str
|
||||||
|
|
||||||
|
|
||||||
|
class MnemonicResponse(BaseModel):
|
||||||
|
mnemonic: str
|
||||||
|
|
||||||
|
|
||||||
|
class SecretKeyResponse(BaseModel):
|
||||||
|
chain: str
|
||||||
|
address: str
|
||||||
|
derivation_path: str
|
||||||
|
private_key: str
|
||||||
|
|
||||||
|
|
||||||
|
class CreatePurchaseRequestBody(BaseModel):
|
||||||
|
organization_id: str
|
||||||
|
rub_amount: Decimal = Field(gt=0)
|
||||||
|
status: str = 'submitted'
|
||||||
|
usdt_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
exchange_rate: Decimal | None = Field(default=None, gt=0)
|
||||||
|
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
||||||
|
comment: str | None = None
|
||||||
|
admin_comment: str | None = None
|
||||||
|
target_wallet_chain: str | None = Field(default='ETH', max_length=16)
|
||||||
|
target_wallet_address: str | None = Field(default=None, max_length=128)
|
||||||
|
tx_hash: str | None = Field(default=None, max_length=128)
|
||||||
|
assigned_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePurchaseRequestStatusBody(BaseModel):
|
||||||
|
status: str
|
||||||
|
admin_comment: str | None = None
|
||||||
|
assigned_to: str | None = None
|
||||||
|
tx_hash: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class UpdatePurchaseRequestBody(BaseModel):
|
||||||
|
status: str | None = None
|
||||||
|
rub_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
usdt_amount: Decimal | None = Field(default=None, gt=0)
|
||||||
|
exchange_rate: Decimal | None = Field(default=None, gt=0)
|
||||||
|
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
||||||
|
comment: str | None = None
|
||||||
|
admin_comment: str | None = None
|
||||||
|
target_wallet_chain: str | None = Field(default=None, max_length=16)
|
||||||
|
target_wallet_address: str | None = Field(default=None, max_length=128)
|
||||||
|
tx_hash: str | None = Field(default=None, max_length=128)
|
||||||
|
assigned_to: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class SetPurchaseRequestQuoteBody(BaseModel):
|
||||||
|
rub_amount: Decimal = Field(gt=0)
|
||||||
|
exchange_rate: Decimal = Field(gt=0)
|
||||||
|
service_fee_percent: Decimal | None = None
|
||||||
|
admin_comment: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequestResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
organization_id: str
|
||||||
|
status: str
|
||||||
|
rub_amount: str
|
||||||
|
usdt_amount: str | None
|
||||||
|
exchange_rate: str | None
|
||||||
|
service_fee_percent: str | None
|
||||||
|
comment: str | None
|
||||||
|
admin_comment: str | None
|
||||||
|
target_wallet_chain: str | None
|
||||||
|
target_wallet_address: str | None
|
||||||
|
tx_hash: str | None
|
||||||
|
assigned_to: str | None
|
||||||
|
created_at: str | None
|
||||||
|
updated_at: str | None
|
||||||
|
completed_at: str | None
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequestListResponse(BaseModel):
|
||||||
|
items: list[PurchaseRequestResponse]
|
||||||
|
total: int
|
||||||
@@ -2,16 +2,37 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from src.application.domain.entities.organization import (
|
from src.application.domain.entities.organization import (
|
||||||
LegalEntityEntity,
|
LegalEntityEntity,
|
||||||
OrganizationDocumentEntity,
|
OrganizationDocumentSlot,
|
||||||
OrganizationWalletEntity,
|
OrganizationWalletEntity,
|
||||||
PurchaseRequestEntity,
|
PurchaseRequestEntity,
|
||||||
)
|
)
|
||||||
|
from src.application.domain.entities.party_search import (
|
||||||
|
PartySearchEntity,
|
||||||
|
)
|
||||||
|
from src.application.domain.entities.user import (
|
||||||
|
UserEntity,
|
||||||
|
)
|
||||||
|
from src.application.domain.entities.order import (
|
||||||
|
OrderEntity,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.user import (
|
||||||
|
UserResponse,
|
||||||
|
)
|
||||||
from src.presentation.schemas.organization import (
|
from src.presentation.schemas.organization import (
|
||||||
DocumentResponse,
|
DocumentResponse,
|
||||||
OrganizationResponse,
|
OrganizationResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.entity import (
|
||||||
|
CreateWalletsResponse,
|
||||||
|
MnemonicResponse,
|
||||||
|
PartySearchResponse,
|
||||||
PurchaseRequestResponse,
|
PurchaseRequestResponse,
|
||||||
|
SecretKeyResponse,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
)
|
)
|
||||||
|
from src.presentation.schemas.order import (
|
||||||
|
OrderResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
|
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
|
||||||
@@ -38,6 +59,21 @@ def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def party_search_to_response(entity: PartySearchEntity) -> PartySearchResponse:
|
||||||
|
return PartySearchResponse(
|
||||||
|
id=entity.id,
|
||||||
|
account_type=entity.account_type,
|
||||||
|
user_id=entity.user_id,
|
||||||
|
email=entity.email,
|
||||||
|
name=entity.name,
|
||||||
|
inn=entity.inn,
|
||||||
|
phone=entity.phone,
|
||||||
|
status=entity.status,
|
||||||
|
kyc_verified=entity.kyc_verified,
|
||||||
|
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
|
def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
|
||||||
return WalletResponse(
|
return WalletResponse(
|
||||||
id=entity.id,
|
id=entity.id,
|
||||||
@@ -48,20 +84,44 @@ def wallet_to_response(entity: OrganizationWalletEntity) -> WalletResponse:
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def create_wallets_to_response(*, wallets: list[OrganizationWalletEntity], mnemonic: str) -> CreateWalletsResponse:
|
||||||
|
return CreateWalletsResponse(
|
||||||
|
wallets=[wallet_to_response(w) for w in wallets],
|
||||||
|
mnemonic=mnemonic,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def mnemonic_to_response(mnemonic: str) -> MnemonicResponse:
|
||||||
|
return MnemonicResponse(mnemonic=mnemonic)
|
||||||
|
|
||||||
|
|
||||||
|
def secret_key_to_response(
|
||||||
|
*,
|
||||||
|
chain: str,
|
||||||
|
address: str,
|
||||||
|
derivation_path: str,
|
||||||
|
private_key: str,
|
||||||
|
) -> SecretKeyResponse:
|
||||||
|
return SecretKeyResponse(
|
||||||
|
chain=chain,
|
||||||
|
address=address,
|
||||||
|
derivation_path=derivation_path,
|
||||||
|
private_key=private_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def document_to_response(
|
def document_to_response(
|
||||||
entity: OrganizationDocumentEntity,
|
entity: OrganizationDocumentSlot,
|
||||||
*,
|
*,
|
||||||
download_url: str | None = None,
|
download_url: str | None = None,
|
||||||
) -> DocumentResponse:
|
) -> DocumentResponse:
|
||||||
return DocumentResponse(
|
return DocumentResponse(
|
||||||
id=entity.id,
|
|
||||||
organization_id=entity.organization_id,
|
organization_id=entity.organization_id,
|
||||||
document_type=entity.document_type,
|
document_type=entity.document_type,
|
||||||
|
s3_key=entity.s3_key,
|
||||||
file_name=entity.file_name,
|
file_name=entity.file_name,
|
||||||
content_type=entity.content_type,
|
content_type=entity.content_type,
|
||||||
file_size_bytes=entity.file_size_bytes,
|
file_size_bytes=entity.file_size_bytes,
|
||||||
uploaded_by=entity.uploaded_by,
|
|
||||||
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
|
||||||
download_url=download_url,
|
download_url=download_url,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -85,3 +145,36 @@ def purchase_request_to_response(entity: PurchaseRequestEntity) -> PurchaseReque
|
|||||||
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||||
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
|
completed_at=entity.completed_at.isoformat() if entity.completed_at else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def user_to_response(entity: UserEntity) -> UserResponse:
|
||||||
|
return UserResponse(
|
||||||
|
id=entity.id,
|
||||||
|
email=entity.email,
|
||||||
|
first_name=entity.first_name,
|
||||||
|
middle_name=entity.middle_name,
|
||||||
|
last_name=entity.last_name,
|
||||||
|
birth_date=entity.birth_date,
|
||||||
|
phone=entity.phone,
|
||||||
|
passport_data=entity.passport_data,
|
||||||
|
inn=entity.inn,
|
||||||
|
avatar_link=entity.avatar_link,
|
||||||
|
account_type=entity.account_type,
|
||||||
|
kyc_verified=entity.kyc_verified,
|
||||||
|
is_deleted=entity.is_deleted,
|
||||||
|
created_at=entity.created_at.isoformat() if entity.created_at else None,
|
||||||
|
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||||
|
kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def order_to_response(entity: OrderEntity) -> OrderResponse:
|
||||||
|
return OrderResponse(
|
||||||
|
order_id=entity.id,
|
||||||
|
order_status=entity.status,
|
||||||
|
usdt_amount=str(entity.usdt_amount),
|
||||||
|
usdt_exchange_rate=str(entity.usdt_exchange_rate) if entity.usdt_exchange_rate is not None else None,
|
||||||
|
gas_fee=str(entity.gas_fee) if entity.gas_fee is not None else None,
|
||||||
|
total_price=str(entity.total_price) if entity.total_price is not None else None,
|
||||||
|
service_fee=str(entity.service_fee) if entity.service_fee is not None else None,
|
||||||
|
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||||
|
)
|
||||||
|
|||||||
50
src/presentation/schemas/order.py
Normal file
50
src/presentation/schemas/order.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDetailResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
usdt_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
gas_fee: str | None = None
|
||||||
|
total_price: str | None = None
|
||||||
|
service_fee: str | None = None
|
||||||
|
status: OrderStatus | None = None
|
||||||
|
client_payment_id: str | None = None
|
||||||
|
itpay_payment_qr_url_desktop: str | None = None
|
||||||
|
itpay_payment_qr_url_android: str | None = None
|
||||||
|
itpay_payment_qr_url_ios: str | None = None
|
||||||
|
itpay_payment_qr_image_desktop: str | None = None
|
||||||
|
itpay_payment_qr_image_android: str | None = None
|
||||||
|
itpay_payment_qr_image_ios: str | None = None
|
||||||
|
itpay_id: str | None = None
|
||||||
|
itpay_qr_id: str | None = None
|
||||||
|
itpay_amount: str | None = None
|
||||||
|
itpay_created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_status: OrderStatus | None = None
|
||||||
|
usdt_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
gas_fee: str | None = None
|
||||||
|
total_price: str | None = None
|
||||||
|
service_fee: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrdersResponse(BaseModel):
|
||||||
|
items: list[OrderResponse]
|
||||||
|
total: int
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
class OrdersByDateResponse(BaseModel):
|
||||||
|
items: list[OrderResponse]
|
||||||
|
total: int
|
||||||
|
summed_service_fees: Decimal
|
||||||
|
next_cursor: str | None = None
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -62,59 +61,11 @@ class OrganizationListResponse(BaseModel):
|
|||||||
total: int
|
total: int
|
||||||
|
|
||||||
|
|
||||||
class WalletResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
chain: str
|
|
||||||
address: str
|
|
||||||
derivation_path: str
|
|
||||||
created_at: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class DocumentResponse(BaseModel):
|
class DocumentResponse(BaseModel):
|
||||||
id: str
|
|
||||||
organization_id: str
|
organization_id: str
|
||||||
document_type: str
|
document_type: str
|
||||||
file_name: str
|
s3_key: str | None
|
||||||
content_type: str
|
file_name: str | None
|
||||||
file_size_bytes: int
|
content_type: str | None = None
|
||||||
uploaded_by: str | None
|
file_size_bytes: int | None = None
|
||||||
created_at: str | None
|
|
||||||
download_url: str | None = None
|
download_url: str | None = None
|
||||||
|
|
||||||
|
|
||||||
class UpdatePurchaseRequestStatusBody(BaseModel):
|
|
||||||
status: str
|
|
||||||
admin_comment: str | None = None
|
|
||||||
assigned_to: str | None = None
|
|
||||||
tx_hash: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class SetPurchaseRequestQuoteBody(BaseModel):
|
|
||||||
rub_amount: Decimal = Field(gt=0)
|
|
||||||
exchange_rate: Decimal = Field(gt=0)
|
|
||||||
service_fee_percent: Decimal | None = None
|
|
||||||
admin_comment: str | None = None
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseRequestResponse(BaseModel):
|
|
||||||
id: str
|
|
||||||
organization_id: str
|
|
||||||
status: str
|
|
||||||
usdt_amount: str
|
|
||||||
rub_amount: str | None
|
|
||||||
exchange_rate: str | None
|
|
||||||
service_fee_percent: str | None
|
|
||||||
comment: str | None
|
|
||||||
admin_comment: str | None
|
|
||||||
target_wallet_chain: str | None
|
|
||||||
target_wallet_address: str | None
|
|
||||||
tx_hash: str | None
|
|
||||||
assigned_to: str | None
|
|
||||||
created_at: str | None
|
|
||||||
updated_at: str | None
|
|
||||||
completed_at: str | None
|
|
||||||
|
|
||||||
|
|
||||||
class PurchaseRequestListResponse(BaseModel):
|
|
||||||
items: list[PurchaseRequestResponse]
|
|
||||||
total: int
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import re
|
|
||||||
from pydantic import BaseModel, field_validator
|
from pydantic import BaseModel, field_validator
|
||||||
from src.application.domain.password_policy import validate_password_strength
|
from src.application.domain.password_policy import validate_password_strength
|
||||||
|
|
||||||
|
|
||||||
class SetPasswordRequest(BaseModel):
|
class SetPasswordRequest(BaseModel):
|
||||||
email: str
|
email: str | None = None
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
@field_validator('password')
|
@field_validator('password')
|
||||||
|
|||||||
53
src/presentation/schemas/risk.py
Normal file
53
src/presentation/schemas/risk.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None = None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str]
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentsResponse(BaseModel):
|
||||||
|
items: list[RiskAssessmentResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSummaryResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None = None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventsResponse(BaseModel):
|
||||||
|
items: list[AuditEventResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class FunnelResponse(BaseModel):
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
30
src/presentation/schemas/user.py
Normal file
30
src/presentation/schemas/user.py
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
|
||||||
|
class UserResponse(BaseModel):
|
||||||
|
id: str | None = Field(None, description='Идентификатор пользователя')
|
||||||
|
email: str | None = Field(None, description='Email')
|
||||||
|
first_name: str | None = Field(None, description='Имя')
|
||||||
|
middle_name: str | None = Field(None, description='Отчество')
|
||||||
|
last_name: str | None = Field(None, description='Фамилия')
|
||||||
|
birth_date: date | None = Field(None, description='Дата рождения')
|
||||||
|
phone: str | None = Field(None, description='Телефон')
|
||||||
|
passport_data: str | None = Field(None, description='Паспортные данные')
|
||||||
|
inn: str | None = Field(None, description='ИНН')
|
||||||
|
avatar_link: str | None = Field(None, description='HTTPS-ссылка на текущий аватар в хранилище')
|
||||||
|
account_type: str | None = Field(None, description='Тип аккаунта')
|
||||||
|
kyc_verified: bool | None = Field(None, description='Признак пройденного KYC')
|
||||||
|
is_deleted: bool | None = Field(None, description='Удалён ли аккаунт')
|
||||||
|
created_at: datetime | None = Field(None, description='Время создания записи')
|
||||||
|
updated_at: datetime | None = Field(None, description='Время последнего обновления')
|
||||||
|
kyc_verified_at: datetime | None = Field(None, description='Время подтверждения KYC')
|
||||||
|
|
||||||
|
|
||||||
|
class UserListResponse(BaseModel):
|
||||||
|
items: list[UserResponse]
|
||||||
|
total: int
|
||||||
|
next_cursor: str | None = None
|
||||||
Reference in New Issue
Block a user