Compare commits

19 Commits

Author SHA1 Message Date
Codex
e2834a7a52 Document risk frontend requirements 2026-07-05 16:37:23 +03:00
Codex
a5600c75ae Add risk and audit admin APIs 2026-07-05 16:35:54 +03:00
Codex
f9506be2df Add cursor pagination to admin lists 2026-07-02 13:13:09 +03:00
Codex
4c07cb1f29 Add healthcheck endpoint 2026-07-01 12:21:49 +03:00
0bea5bfb5a fix: remove hash value logging 2026-06-28 14:12:40 +03:00
2b9b54715f chore: clear unused imports 2026-06-23 15:50:58 +03:00
4490482f48 refactor: party search imports 2026-06-23 15:40:04 +03:00
1247b79e0b feat: add order and purchase request data for analytics 2026-06-18 18:37:04 +03:00
8ebe016591 feat: add summary to completed orders 2026-06-17 13:19:16 +03:00
909f4d9298 feat: add view order history and filter by date 2026-06-17 13:01:26 +03:00
4bdf295007 feat: add health check endpoint 2026-06-16 11:40:08 +03:00
9243d3db2d refactor: change title in swagger 2026-06-15 14:07:17 +03:00
0346493648 refactor: swap currency 2026-06-15 13:53:47 +03:00
a97605c478 feat: add set password by email and by id 2026-06-15 11:24:33 +03:00
db30feb0e2 refactor: change search router 2026-06-15 11:01:39 +03:00
6f8206c867 Merge pull request 'Func indviduals' from admin_dev into main
Reviewed-on: #2
2026-06-11 15:58:38 +00:00
4aae631c73 feat: add user endpoints 2026-06-11 18:46:21 +03:00
e0f044b455 refactor: split org responses for unification 2026-06-11 13:25:45 +03:00
99714ac476 fix: password change bugs 2026-06-10 20:09:40 +03:00
59 changed files with 2640 additions and 237 deletions

View 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.

View File

@@ -9,6 +9,9 @@ from src.application.abstractions.repositories import (
IOrganizationWalletRepository, IOrganizationWalletRepository,
IPurchaseRequestRepository, IPurchaseRequestRepository,
IUserRepository, IUserRepository,
IWalletRepository,
IOrderRepository,
IRiskRepository,
) )
@@ -37,3 +40,12 @@ class IUnitOfWork(Protocol):
@property @property
def purchase_request_repository(self) -> IPurchaseRequestRepository: ... def purchase_request_repository(self) -> IPurchaseRequestRepository: ...
@property
def wallet_repository(self) -> IWalletRepository: ...
@property
def order_repository(self) -> IOrderRepository: ...
@property
def risk_repository(self) -> IRiskRepository: ...

View File

@@ -4,3 +4,6 @@ from src.application.abstractions.repositories.i_admin_session_repository import
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_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

View File

@@ -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

View File

@@ -31,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

View File

@@ -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

View File

@@ -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:
@@ -34,11 +42,11 @@ class IUserRepository(ABC):
@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

View File

@@ -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

View File

@@ -6,10 +6,10 @@ from src.application.commands.create_organization import CreateOrganizationComma
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.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 (
@@ -29,6 +29,30 @@ from src.application.commands.purchase_request_commands import (
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',
@@ -54,4 +78,23 @@ __all__ = [
'GetOrganizationMnemonicCommand', 'GetOrganizationMnemonicCommand',
'GetOrganizationSecretKeysCommand', 'GetOrganizationSecretKeysCommand',
'SetPasswordCommand', 'SetPasswordCommand',
'ListUsersCommand',
'GetUserCommand',
'ListUserWalletsCommand',
'GetUserMnemonicCommand',
'GetUserSecretKeysCommand',
'ListOrdersCommand',
'ListUserOrdersCommand',
'ListOrdersByDateCommand',
'GetOrderCommand',
'GetPurchaseAnalyticsForYearCommand',
'GetOrdersAnalyticsForYearCommand',
'GetPurchaseAnalyticsForMonthCommand',
'GetOrdersAnalyticsForMonthCommand',
'GetPurchaseAnalyticsForWeekCommand',
'GetOrdersAnalyticsForWeekCommand',
'ListRiskAssessmentsCommand',
'GetRiskSummaryCommand',
'ListAuditEventsCommand',
'GetFunnelCommand',
] ]

View 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

View 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

View File

@@ -4,8 +4,7 @@ from typing import Any
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 LegalEntityEntity, PartySearchEntity from src.application.domain.entities.organization import LegalEntityEntity
from src.application.domain.enums.account_type import AccountType
from src.infrastructure.database.decorators import transactional from src.infrastructure.database.decorators import transactional
@@ -31,49 +30,6 @@ class ListOrganizationsCommand:
return items, total return items, total
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,
)
class GetOrganizationCommand: class GetOrganizationCommand:
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

View 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,
)

View File

@@ -64,9 +64,9 @@ class CreatePurchaseRequestCommand:
self, self,
*, *,
organization_id: str, organization_id: str,
usdt_amount: Decimal, rub_amount: Decimal,
status: str = 'submitted', status: str = 'submitted',
rub_amount: Decimal | None = None, usdt_amount: Decimal | None = None,
exchange_rate: Decimal | None = None, exchange_rate: Decimal | None = None,
service_fee_percent: Decimal | None = None, service_fee_percent: Decimal | None = None,
comment: str | None = None, comment: str | None = None,
@@ -83,8 +83,8 @@ class CreatePurchaseRequestCommand:
values: dict[str, Any] = { values: dict[str, Any] = {
'organization_id': organization_id, 'organization_id': organization_id,
'status': status, 'status': status,
'usdt_amount': usdt_amount,
'rub_amount': rub_amount, 'rub_amount': rub_amount,
'usdt_amount': usdt_amount,
'exchange_rate': exchange_rate, 'exchange_rate': exchange_rate,
'service_fee_percent': service_fee_percent, 'service_fee_percent': service_fee_percent,
'comment': comment, 'comment': comment,

View 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()

View File

@@ -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

View 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)

View 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

View File

@@ -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']

View 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

View File

@@ -56,27 +56,13 @@ class OrganizationDocumentSlot:
file_size_bytes: int | None = None file_size_bytes: int | None = None
@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
@dataclass @dataclass
class PurchaseRequestEntity: 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

View 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

View 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

View File

@@ -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

View File

@@ -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

View File

@@ -0,0 +1,9 @@
from enum import Enum
class OrderStatus(str, Enum):
PENDING = 'pending'
REJECTED = 'rejected'
COMPLETED = 'completed'
CANCELLED = 'cancelled'
ERROR = 'error'

View File

@@ -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)

View File

@@ -5,6 +5,9 @@ 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.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,4 +17,6 @@ __all__ = [
'LegalEntityModel', 'LegalEntityModel',
'OrganizationWalletModel', 'OrganizationWalletModel',
'PurchaseRequestModel', 'PurchaseRequestModel',
'WalletModel',
'OrderModel',
] ]

View 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
)

View File

@@ -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)

View 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())

View 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(),
)

View File

@@ -4,6 +4,9 @@ from src.infrastructure.database.repositories.user_repository import UserReposit
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.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,4 +15,6 @@ __all__ = [
'LegalEntityRepository', 'LegalEntityRepository',
'OrganizationWalletRepository', 'OrganizationWalletRepository',
'PurchaseRequestRepository', 'PurchaseRequestRepository',
'WalletRepository',
'OrderRepository',
] ]

View File

@@ -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

View 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())

View File

@@ -90,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())

View 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,
)

View File

@@ -2,7 +2,6 @@ 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 func, or_, 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
@@ -10,7 +9,7 @@ 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.organization import PartySearchEntity 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
@@ -45,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,
@@ -74,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()
@@ -110,6 +134,45 @@ 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): def _individual_search_filter(self, query: str):
pattern = f'%{query.strip()}%' pattern = f'%{query.strip()}%'
@@ -176,3 +239,14 @@ class UserRepository(IUserRepository):
) )
result = await self._session.execute(stmt) result = await self._session.execute(stmt)
return int(result.scalar_one()) 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())

View File

@@ -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

View File

@@ -8,6 +8,9 @@ from src.application.abstractions.repositories import (
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
@@ -18,6 +21,9 @@ from src.infrastructure.database.repositories import (
OrganizationWalletRepository, OrganizationWalletRepository,
PurchaseRequestRepository, PurchaseRequestRepository,
UserRepository, UserRepository,
WalletRepository,
OrderRepository,
RiskRepository,
) )
@@ -31,10 +37,14 @@ class UnitOfWork(IUnitOfWork):
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._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):
@@ -84,6 +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
def wallet_repository(self) -> IWalletRepository:
if self._wallet_repository is None:
self._wallet_repository = WalletRepository(
session=self._session, logger=self._logger
)
return self._wallet_repository
@property @property
def purchase_request_repository(self) -> IPurchaseRequestRepository: def purchase_request_repository(self) -> IPurchaseRequestRepository:
@@ -92,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

View File

@@ -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())

View File

@@ -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

View File

@@ -27,6 +27,25 @@ from src.application.commands import (
UpdatePurchaseRequestStatusCommand, UpdatePurchaseRequestStatusCommand,
PutOrganizationDocumentCommand, PutOrganizationDocumentCommand,
SetPasswordCommand, 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
@@ -215,3 +234,117 @@ def get_set_password_command(
logger: ILogger = Depends(get_logger), logger: ILogger = Depends(get_logger),
) -> SetPasswordCommand: ) -> SetPasswordCommand:
return SetPasswordCommand(uow, hash_service, logger) 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)

View File

@@ -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)

View 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__)

View 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,
)

View File

@@ -8,7 +8,6 @@ from src.application.commands import (
GetOrganizationSecretKeysCommand, GetOrganizationSecretKeysCommand,
ListOrganizationWalletsCommand, ListOrganizationWalletsCommand,
ListOrganizationsCommand, ListOrganizationsCommand,
SearchPartiesCommand,
UpdateOrganizationCommand, UpdateOrganizationCommand,
) )
from src.application.domain.dto import AdminAuthContext from src.application.domain.dto import AdminAuthContext
@@ -21,26 +20,25 @@ from src.presentation.dependencies.commands import (
get_get_organization_secret_keys_command, get_get_organization_secret_keys_command,
get_list_organization_wallets_command, get_list_organization_wallets_command,
get_list_organizations_command, get_list_organizations_command,
get_search_parties_command,
get_update_organization_command, get_update_organization_command,
) )
from src.presentation.schemas.mappers import ( from src.presentation.schemas.mappers import (
create_wallets_to_response, create_wallets_to_response,
mnemonic_to_response, mnemonic_to_response,
organization_to_response, organization_to_response,
party_search_to_response,
secret_key_to_response, secret_key_to_response,
wallet_to_response, wallet_to_response,
) )
from src.presentation.schemas.organization import ( from src.presentation.schemas.organization import (
CreateOrganizationRequest, CreateOrganizationRequest,
CreateWalletsResponse,
MnemonicResponse,
OrganizationListResponse, OrganizationListResponse,
OrganizationResponse, OrganizationResponse,
PartySearchListResponse,
SecretKeyResponse,
UpdateOrganizationRequest, UpdateOrganizationRequest,
)
from src.presentation.schemas.entity import (
CreateWalletsResponse,
MnemonicResponse,
SecretKeyResponse,
WalletResponse, WalletResponse,
) )
@@ -87,21 +85,6 @@ async def create_organization(
return organization_to_response(org) return organization_to_response(org)
@organizations_router.get('/search', 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,
)
@organizations_router.get('/{organization_id}', response_model=OrganizationResponse) @organizations_router.get('/{organization_id}', response_model=OrganizationResponse)
async def get_organization( async def get_organization(
organization_id: str, organization_id: str,

View File

@@ -19,7 +19,7 @@ from src.presentation.dependencies.commands import (
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, CreatePurchaseRequestBody,
PurchaseRequestListResponse, PurchaseRequestListResponse,
PurchaseRequestResponse, PurchaseRequestResponse,
@@ -60,9 +60,9 @@ async def create_purchase_request(
): ):
item = await command( item = await command(
organization_id=body.organization_id, organization_id=body.organization_id,
usdt_amount=body.usdt_amount,
status=body.status,
rub_amount=body.rub_amount, rub_amount=body.rub_amount,
status=body.status,
usdt_amount=body.usdt_amount,
exchange_rate=body.exchange_rate, exchange_rate=body.exchange_rate,
service_fee_percent=body.service_fee_percent, service_fee_percent=body.service_fee_percent,
comment=body.comment, comment=body.comment,

View 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,
)

View 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,
)

View File

@@ -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'})

View 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

View 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

View File

@@ -4,19 +4,35 @@ from src.application.domain.entities.organization import (
LegalEntityEntity, LegalEntityEntity,
OrganizationDocumentSlot, OrganizationDocumentSlot,
OrganizationWalletEntity, OrganizationWalletEntity,
PartySearchEntity,
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 (
CreateWalletsResponse,
DocumentResponse, DocumentResponse,
MnemonicResponse,
OrganizationResponse, OrganizationResponse,
)
from src.presentation.schemas.entity import (
CreateWalletsResponse,
MnemonicResponse,
PartySearchResponse, PartySearchResponse,
PurchaseRequestResponse, PurchaseRequestResponse,
SecretKeyResponse, SecretKeyResponse,
WalletResponse, WalletResponse,
) )
from src.presentation.schemas.order import (
OrderResponse,
)
def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse: def organization_to_response(entity: LegalEntityEntity) -> OrganizationResponse:
@@ -129,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,
)

View 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

View File

@@ -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,48 +61,6 @@ class OrganizationListResponse(BaseModel):
total: int total: int
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 DocumentResponse(BaseModel): class DocumentResponse(BaseModel):
organization_id: str organization_id: str
document_type: str document_type: str
@@ -112,70 +69,3 @@ class DocumentResponse(BaseModel):
content_type: str | None = None content_type: str | None = None
file_size_bytes: int | None = None file_size_bytes: int | None = None
download_url: str | None = None download_url: str | None = None
class CreatePurchaseRequestBody(BaseModel):
organization_id: str
usdt_amount: Decimal = Field(gt=0)
status: str = 'submitted'
rub_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
usdt_amount: Decimal | None = Field(default=None, gt=0)
rub_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
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

View File

@@ -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')

View 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

View 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