Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2834a7a52 | ||
|
|
a5600c75ae | ||
|
|
f9506be2df | ||
|
|
4c07cb1f29 | ||
| 0bea5bfb5a | |||
| 2b9b54715f | |||
| 4490482f48 | |||
| 1247b79e0b | |||
| 8ebe016591 | |||
| 909f4d9298 | |||
| 4bdf295007 | |||
| 9243d3db2d | |||
| 0346493648 | |||
| a97605c478 | |||
| db30feb0e2 | |||
| 6f8206c867 |
127
docs/frontend-risk-scoring-requirements.md
Normal file
127
docs/frontend-risk-scoring-requirements.md
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
# Frontend Requirements: Risk, Audit, Funnel
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Frontend code is not changed in this backend release. The admin frontend should add read-only screens for individual-client risk scoring, security audit events, and the product funnel.
|
||||||
|
|
||||||
|
Legal-entity B2B flows are still manual and must not display automatic risk scoring decisions.
|
||||||
|
|
||||||
|
## User Payment Gate
|
||||||
|
|
||||||
|
Individual users cannot create payment orders until KYC is completed.
|
||||||
|
|
||||||
|
Expected client behavior:
|
||||||
|
|
||||||
|
- If the payment API returns `403` with `KYC verification is required before creating payment orders`, show a blocking state.
|
||||||
|
- Primary action: open or continue KYC.
|
||||||
|
- Do not show the QR/payment step.
|
||||||
|
- Do not retry order creation automatically.
|
||||||
|
|
||||||
|
Suggested copy:
|
||||||
|
|
||||||
|
- Title: `Для оплаты нужно пройти KYC`
|
||||||
|
- Body: `После подтверждения личности платежи станут доступны.`
|
||||||
|
- Button: `Пройти KYC`
|
||||||
|
|
||||||
|
## Admin Navigation
|
||||||
|
|
||||||
|
Add two admin sections:
|
||||||
|
|
||||||
|
- `Risk`
|
||||||
|
- `Audit`
|
||||||
|
|
||||||
|
Add one analytics section:
|
||||||
|
|
||||||
|
- `Funnel`
|
||||||
|
|
||||||
|
## Risk Screen
|
||||||
|
|
||||||
|
Backend endpoints:
|
||||||
|
|
||||||
|
- `GET /v1/risk/summary`
|
||||||
|
- `GET /v1/risk/assessments?limit=50&offset=0&decision=&user_id=&order_id=`
|
||||||
|
|
||||||
|
Summary cards:
|
||||||
|
|
||||||
|
- Total assessments
|
||||||
|
- Allowed
|
||||||
|
- Manual review
|
||||||
|
- Rejected
|
||||||
|
- Average score
|
||||||
|
- High risk
|
||||||
|
|
||||||
|
Assessment table columns:
|
||||||
|
|
||||||
|
- Created at
|
||||||
|
- User ID
|
||||||
|
- Order ID
|
||||||
|
- Score
|
||||||
|
- Decision
|
||||||
|
- Reasons
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- Decision: `allow`, `manual_review`, `reject`
|
||||||
|
- User ID
|
||||||
|
- Order ID
|
||||||
|
|
||||||
|
Decision badges:
|
||||||
|
|
||||||
|
- `allow`: green
|
||||||
|
- `manual_review`: yellow
|
||||||
|
- `reject`: red
|
||||||
|
|
||||||
|
## Audit Screen
|
||||||
|
|
||||||
|
Backend endpoint:
|
||||||
|
|
||||||
|
- `GET /v1/audit/events?limit=50&offset=0&severity=&action=&entity_type=`
|
||||||
|
|
||||||
|
Table columns:
|
||||||
|
|
||||||
|
- Created at
|
||||||
|
- Severity
|
||||||
|
- Action
|
||||||
|
- Actor type
|
||||||
|
- Actor ID
|
||||||
|
- Entity type
|
||||||
|
- Entity ID
|
||||||
|
- Metadata
|
||||||
|
|
||||||
|
Filters:
|
||||||
|
|
||||||
|
- Severity
|
||||||
|
- Action
|
||||||
|
- Entity type
|
||||||
|
|
||||||
|
Use collapsible JSON rendering for `metadata`.
|
||||||
|
|
||||||
|
## Funnel Screen
|
||||||
|
|
||||||
|
Backend endpoint:
|
||||||
|
|
||||||
|
- `GET /v1/analytics/funnel`
|
||||||
|
|
||||||
|
Display the funnel as ordered steps:
|
||||||
|
|
||||||
|
1. Registrations
|
||||||
|
2. KYC started
|
||||||
|
3. KYC completed
|
||||||
|
4. First payment
|
||||||
|
5. Successful operations
|
||||||
|
|
||||||
|
For each step after the first, show conversion from the previous step and conversion from registrations.
|
||||||
|
|
||||||
|
## Grafana Links
|
||||||
|
|
||||||
|
Expose optional links from the admin UI:
|
||||||
|
|
||||||
|
- `https://corp.grafana.elcsa.ru/d/elcsa-audit-security/infrastructure-audit-and-security`
|
||||||
|
- `https://corp.grafana.elcsa.ru/d/elcsa-product-funnel/product-funnel`
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- Do not show raw internal risk rule weights to end users.
|
||||||
|
- User-facing apps should show human states such as `Нужна проверка`, not numeric scores.
|
||||||
|
- Admin screens may show score, decision, and reasons.
|
||||||
|
- Legal entities should not use automatic risk scoring until KYB automation is added.
|
||||||
@@ -10,6 +10,8 @@ from src.application.abstractions.repositories import (
|
|||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
IWalletRepository,
|
IWalletRepository,
|
||||||
|
IOrderRepository,
|
||||||
|
IRiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -41,3 +43,9 @@ class IUnitOfWork(Protocol):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def wallet_repository(self) -> IWalletRepository: ...
|
def wallet_repository(self) -> IWalletRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_repository(self) -> IOrderRepository: ...
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository: ...
|
||||||
@@ -5,3 +5,5 @@ from src.application.abstractions.repositories.i_legal_entity_repository import
|
|||||||
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_wallets_repository import IWalletRepository
|
||||||
|
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
|
||||||
|
|
||||||
|
class IRiskRepository(ABC):
|
||||||
|
@abstractmethod
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
raise NotImplementedError
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
raise NotImplementedError
|
||||||
@@ -2,7 +2,7 @@ from abc import ABC, abstractmethod
|
|||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
from src.application.domain.entities import UserEntity
|
from src.application.domain.entities import UserEntity
|
||||||
from src.application.domain.entities.organization import PartySearchEntity
|
from src.application.domain.entities.party_search import PartySearchEntity
|
||||||
|
|
||||||
|
|
||||||
class IUserRepository(ABC):
|
class IUserRepository(ABC):
|
||||||
|
|||||||
@@ -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 (
|
||||||
@@ -38,6 +38,21 @@ from src.application.commands.user_wallet_commands import (
|
|||||||
GetUserSecretKeysCommand,
|
GetUserSecretKeysCommand,
|
||||||
ListUserWalletsCommand,
|
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',
|
||||||
@@ -68,4 +83,18 @@ __all__ = [
|
|||||||
'ListUserWalletsCommand',
|
'ListUserWalletsCommand',
|
||||||
'GetUserMnemonicCommand',
|
'GetUserMnemonicCommand',
|
||||||
'GetUserSecretKeysCommand',
|
'GetUserSecretKeysCommand',
|
||||||
|
'ListOrdersCommand',
|
||||||
|
'ListUserOrdersCommand',
|
||||||
|
'ListOrdersByDateCommand',
|
||||||
|
'GetOrderCommand',
|
||||||
|
'GetPurchaseAnalyticsForYearCommand',
|
||||||
|
'GetOrdersAnalyticsForYearCommand',
|
||||||
|
'GetPurchaseAnalyticsForMonthCommand',
|
||||||
|
'GetOrdersAnalyticsForMonthCommand',
|
||||||
|
'GetPurchaseAnalyticsForWeekCommand',
|
||||||
|
'GetOrdersAnalyticsForWeekCommand',
|
||||||
|
'ListRiskAssessmentsCommand',
|
||||||
|
'GetRiskSummaryCommand',
|
||||||
|
'ListAuditEventsCommand',
|
||||||
|
'GetFunnelCommand',
|
||||||
]
|
]
|
||||||
|
|||||||
239
src/application/commands/analytics_commands.py
Normal file
239
src/application/commands/analytics_commands.py
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from decimal import Decimal
|
||||||
|
from datetime import date, timedelta
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities import OrderEntity, PurchaseRequestEntity
|
||||||
|
from src.application.domain.exceptions import NotFoundException
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
YEAR=12
|
||||||
|
MONTH=30
|
||||||
|
WEEK=7
|
||||||
|
|
||||||
|
def _count_date(
|
||||||
|
year: int,
|
||||||
|
month: int | None = None,
|
||||||
|
day: int | None = None,
|
||||||
|
) -> tuple[int, int | None, int | None]:
|
||||||
|
if month is None:
|
||||||
|
return year - 1, None, None
|
||||||
|
|
||||||
|
if day is None:
|
||||||
|
return (year - 1, 12, None) if month == 1 else (year, month - 1, None)
|
||||||
|
|
||||||
|
prev_date = date(year, month, day) - timedelta(days=1)
|
||||||
|
return prev_date.year, prev_date.month, prev_date.day
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForYearCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[int], list[Decimal], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month = start_year, start_month
|
||||||
|
|
||||||
|
for _ in range(YEAR):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, _ = _count_date(year, month)
|
||||||
|
return orders, sums, sum(orders), sum(sums), YEAR
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForYearCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[int], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month = start_year, start_month
|
||||||
|
|
||||||
|
for _ in range(YEAR):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_month(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, _ = _count_date(year, month)
|
||||||
|
return requests, sum(requests), YEAR
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForMonthCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int | None = None,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[int], list[Decimal], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(MONTH):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return orders, sums, sum(orders), sum(sums), MONTH
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForMonthCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int | None = None,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[PurchaseRequestEntity], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(MONTH):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return requests, sum(requests), MONTH
|
||||||
|
|
||||||
|
|
||||||
|
class GetPurchaseAnalyticsForWeekCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int,
|
||||||
|
status: str | None = None,
|
||||||
|
organization_id: str | None = None,
|
||||||
|
) -> tuple[list[PurchaseRequestEntity], int, int]:
|
||||||
|
requests: list[int] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(WEEK):
|
||||||
|
request_count = await self._unit_of_work.purchase_request_repository.count_for_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
|
||||||
|
requests.append(request_count)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return requests, sum(requests), WEEK
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrdersAnalyticsForWeekCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
start_year: int,
|
||||||
|
start_month: int,
|
||||||
|
start_day: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> tuple[list[OrderEntity], int, Decimal, int]:
|
||||||
|
orders: list[int] = []
|
||||||
|
sums: list[Decimal] = []
|
||||||
|
year, month, day = start_year, start_month, start_day
|
||||||
|
|
||||||
|
for _ in range(WEEK):
|
||||||
|
order_count = await self._unit_of_work.order_repository.count_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
order_sum = await self._unit_of_work.order_repository.sum_by_day(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
day=day,
|
||||||
|
search=search,
|
||||||
|
)
|
||||||
|
|
||||||
|
orders.append(order_count)
|
||||||
|
sums.append(order_sum)
|
||||||
|
year, month, day = _count_date(year, month, day)
|
||||||
|
return orders, sums, sum(orders), sum(sums), WEEK
|
||||||
101
src/application/commands/orders_commands.py
Normal file
101
src/application/commands/orders_commands.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from datetime import datetime, timezone
|
||||||
|
from decimal import Decimal
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities import OrderEntity
|
||||||
|
from src.application.domain.exceptions import NotFoundException
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_cursor(cursor: str | None) -> datetime | None:
|
||||||
|
if not cursor:
|
||||||
|
return None
|
||||||
|
parsed = datetime.fromisoformat(cursor.replace('Z','+00:00'))
|
||||||
|
if parsed.tzinfo is None:
|
||||||
|
return parsed.replace(tzinfo=timezone.utc)
|
||||||
|
return parsed
|
||||||
|
|
||||||
|
|
||||||
|
class ListUserOrdersCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, user_id: str, limit: int, offset: int, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_by_user_id(
|
||||||
|
user_id=user_id,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
total = await self._unit_of_work.order_repository.count_all(search=user_id)
|
||||||
|
return orders, total
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class ListOrdersCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, search: str | None = None, cursor: str | None = None) -> tuple[list[OrderEntity], int]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_all(
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
search=search,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await self._unit_of_work.order_repository.count_all(search=search)
|
||||||
|
|
||||||
|
return orders, total
|
||||||
|
|
||||||
|
|
||||||
|
class ListOrdersByDateCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
year: int,
|
||||||
|
month: int | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
|
) -> tuple[list[OrderEntity], int, Decimal]:
|
||||||
|
orders = await self._unit_of_work.order_repository.list_by_date(
|
||||||
|
year=year,
|
||||||
|
month=month,
|
||||||
|
limit=limit,
|
||||||
|
offset=offset,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
|
)
|
||||||
|
|
||||||
|
total = await self._unit_of_work.order_repository.count_by_month(year=year, month=month)
|
||||||
|
summary = await self._unit_of_work.order_repository.sum_by_month(year=year, month=month)
|
||||||
|
|
||||||
|
return orders, total, summary
|
||||||
|
|
||||||
|
|
||||||
|
class GetOrderCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, order_id: str) -> OrderEntity:
|
||||||
|
order = await self._unit_of_work.order_repository.get_by_id(order_id=order_id)
|
||||||
|
if order is None:
|
||||||
|
raise NotFoundException(message='Order not found')
|
||||||
|
|
||||||
|
return order
|
||||||
@@ -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
|
||||||
|
|||||||
51
src/application/commands/party_search_command.py
Normal file
51
src/application/commands/party_search_command.py
Normal file
@@ -0,0 +1,51 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.organization import LegalEntityEntity
|
||||||
|
from src.application.domain.entities.party_search import PartySearchEntity
|
||||||
|
from src.application.domain.enums.account_type import AccountType
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class SearchPartiesCommand:
|
||||||
|
def __init__(self, unit_of_work: IUnitOfWork, logger: ILogger):
|
||||||
|
self._unit_of_work = unit_of_work
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, query: str, limit: int = 50, offset: int = 0) -> tuple[list[PartySearchEntity], int]:
|
||||||
|
query = query.strip()
|
||||||
|
if not query:
|
||||||
|
return [], 0
|
||||||
|
|
||||||
|
fetch_limit = limit + offset
|
||||||
|
legal_items = await self._unit_of_work.legal_entity_repository.list_all(
|
||||||
|
limit=fetch_limit,
|
||||||
|
offset=0,
|
||||||
|
search=query,
|
||||||
|
)
|
||||||
|
individual_items = await self._unit_of_work.user_repository.search_individuals(
|
||||||
|
query=query,
|
||||||
|
limit=fetch_limit,
|
||||||
|
offset=0,
|
||||||
|
)
|
||||||
|
legal_total = await self._unit_of_work.legal_entity_repository.count_all(search=query)
|
||||||
|
individual_total = await self._unit_of_work.user_repository.count_individuals(query=query)
|
||||||
|
items = [self._legal_to_search_entity(item) for item in legal_items] + individual_items
|
||||||
|
items.sort(key=lambda item: item.created_at.timestamp() if item.created_at else 0, reverse=True)
|
||||||
|
return items[offset:offset + limit], legal_total + individual_total
|
||||||
|
|
||||||
|
def _legal_to_search_entity(self, item: LegalEntityEntity) -> PartySearchEntity:
|
||||||
|
return PartySearchEntity(
|
||||||
|
id=item.id,
|
||||||
|
account_type=AccountType.LEGAL_ENTITY,
|
||||||
|
user_id=item.user_id,
|
||||||
|
email=None,
|
||||||
|
name=item.name,
|
||||||
|
inn=item.inn,
|
||||||
|
phone=item.contact_phone,
|
||||||
|
status=item.status,
|
||||||
|
kyc_verified=item.kyc_verified,
|
||||||
|
created_at=item.created_at,
|
||||||
|
)
|
||||||
@@ -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,
|
||||||
|
|||||||
41
src/application/commands/risk_commands.py
Normal file
41
src/application/commands/risk_commands.py
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from src.application.abstractions import IUnitOfWork
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
|
class ListRiskAssessmentsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_assessments(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
|
||||||
|
|
||||||
|
class GetRiskSummaryCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> RiskSummaryEntity:
|
||||||
|
return await self._uow.risk_repository.summary()
|
||||||
|
|
||||||
|
|
||||||
|
class ListAuditEventsCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
return await self._uow.risk_repository.list_audit_events(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
|
||||||
|
|
||||||
|
class GetFunnelCommand:
|
||||||
|
def __init__(self, uow: IUnitOfWork):
|
||||||
|
self._uow = uow
|
||||||
|
|
||||||
|
@transactional
|
||||||
|
async def __call__(self) -> FunnelEntity:
|
||||||
|
return await self._uow.risk_repository.funnel()
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from src.application.abstractions import IUnitOfWork
|
from src.application.abstractions import IUnitOfWork
|
||||||
from src.application.contracts import IHashService, ILogger
|
from src.application.contracts import IHashService, ILogger
|
||||||
from src.application.domain.exceptions import ApplicationException
|
from src.application.domain.exceptions import ApplicationException, BadRequestException
|
||||||
from src.infrastructure.database.decorators import transactional
|
from src.infrastructure.database.decorators import transactional
|
||||||
|
|
||||||
|
|
||||||
@@ -16,9 +16,21 @@ class SetPasswordCommand:
|
|||||||
self._logger = logger
|
self._logger = logger
|
||||||
|
|
||||||
@transactional
|
@transactional
|
||||||
async def __call__(self, user_id: str, password: str) -> bool:
|
async def __call__(
|
||||||
|
self,
|
||||||
|
password: str,
|
||||||
|
email: str | None = None,
|
||||||
|
user_id: str | None = None,
|
||||||
|
) -> bool:
|
||||||
try:
|
try:
|
||||||
password_hash = await self._hash_service.hash(password)
|
password_hash = await self._hash_service.hash(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,
|
||||||
|
|||||||
@@ -1,15 +1,22 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import Any
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
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 PartySearchEntity
|
|
||||||
from src.application.domain.entities.user import UserEntity
|
from src.application.domain.entities.user import UserEntity
|
||||||
from src.application.domain.enums.account_type import AccountType
|
|
||||||
from src.infrastructure.database.decorators import transactional
|
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:
|
class ListUsersCommand:
|
||||||
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
|
||||||
@@ -22,11 +29,13 @@ class ListUsersCommand:
|
|||||||
limit: int = 50,
|
limit: int = 50,
|
||||||
offset: int = 0,
|
offset: int = 0,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
cursor: str | None = None,
|
||||||
) -> tuple[list[UserEntity], int]:
|
) -> tuple[list[UserEntity], int]:
|
||||||
items = await self._unit_of_work.user_repository.list_all(
|
items = await self._unit_of_work.user_repository.list_all(
|
||||||
limit=limit,
|
limit=limit,
|
||||||
offset=offset,
|
offset=offset,
|
||||||
search=search,
|
search=search,
|
||||||
|
cursor_created_at=_parse_cursor(cursor),
|
||||||
)
|
)
|
||||||
total = await self._unit_of_work.user_repository.count_all(search=search)
|
total = await self._unit_of_work.user_repository.count_all(search=search)
|
||||||
return items, total
|
return items, total
|
||||||
|
|||||||
@@ -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', 'OrderEntity', 'PurchaseRequestEntity']
|
||||||
|
|
||||||
__all__ = ['UserEntity', 'AdminUserEntity', 'AdminSessionEntity']
|
|
||||||
|
|||||||
36
src/application/domain/entities/order.py
Normal file
36
src/application/domain/entities/order.py
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class OrderEntity:
|
||||||
|
id: str | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
|
user_id: str | None = None
|
||||||
|
usdt_amount: Decimal | None = None
|
||||||
|
usdt_exchange_rate: Decimal | None = None
|
||||||
|
gas_fee: Decimal | None = None
|
||||||
|
total_price: Decimal | None = None
|
||||||
|
service_fee: Decimal | None = None
|
||||||
|
status: OrderStatus | None = None
|
||||||
|
|
||||||
|
client_payment_id: str | None = None
|
||||||
|
|
||||||
|
itpay_payment_qr_url_desktop: str | None = None
|
||||||
|
itpay_payment_qr_url_android: str | None = None
|
||||||
|
itpay_payment_qr_url_ios: str | None = None
|
||||||
|
|
||||||
|
itpay_payment_qr_image_desktop: str | None = None
|
||||||
|
itpay_payment_qr_image_android: str | None = None
|
||||||
|
itpay_payment_qr_image_ios: str | None = None
|
||||||
|
|
||||||
|
itpay_id: str | None = None
|
||||||
|
itpay_qr_id: str | None = None
|
||||||
|
itpay_amount: Decimal | None = None
|
||||||
|
itpay_created_at: datetime | None = None
|
||||||
|
|
||||||
@@ -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
|
||||||
|
|||||||
17
src/application/domain/entities/party_search.py
Normal file
17
src/application/domain/entities/party_search.py
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PartySearchEntity:
|
||||||
|
id: str
|
||||||
|
account_type: str
|
||||||
|
user_id: str
|
||||||
|
email: str | None
|
||||||
|
name: str | None
|
||||||
|
inn: str | None
|
||||||
|
phone: str | None
|
||||||
|
status: str | None = None
|
||||||
|
kyc_verified: bool | None = None
|
||||||
|
created_at: datetime | None = None
|
||||||
48
src/application/domain/entities/risk.py
Normal file
48
src/application/domain/entities/risk.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskAssessmentEntity:
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str] = field(default_factory=list)
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class AuditEventEntity:
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: datetime | None = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class RiskSummaryEntity:
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class FunnelEntity:
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
@@ -1,2 +1,3 @@
|
|||||||
from src.application.domain.enums.log_level import LogLevel
|
from src.application.domain.enums.log_level import LogLevel
|
||||||
from src.application.domain.enums.log_format import LogFormat
|
from src.application.domain.enums.log_format import LogFormat
|
||||||
|
from src.application.domain.enums.order_status import OrderStatus
|
||||||
9
src/application/domain/enums/order_status.py
Normal file
9
src/application/domain/enums/order_status.py
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
from enum import Enum
|
||||||
|
|
||||||
|
|
||||||
|
class OrderStatus(str, Enum):
|
||||||
|
PENDING = 'pending'
|
||||||
|
REJECTED = 'rejected'
|
||||||
|
COMPLETED = 'completed'
|
||||||
|
CANCELLED = 'cancelled'
|
||||||
|
ERROR = 'error'
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ 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.wallets import WalletModel
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'Base',
|
'Base',
|
||||||
@@ -16,4 +18,5 @@ __all__ = [
|
|||||||
'OrganizationWalletModel',
|
'OrganizationWalletModel',
|
||||||
'PurchaseRequestModel',
|
'PurchaseRequestModel',
|
||||||
'WalletModel',
|
'WalletModel',
|
||||||
|
'OrderModel',
|
||||||
]
|
]
|
||||||
|
|||||||
53
src/infrastructure/database/models/order.py
Normal file
53
src/infrastructure/database/models/order.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import DateTime, Enum as SAEnum, ForeignKey, Numeric, String, Text
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class OrderModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||||
|
__tablename__ = 'orders'
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(
|
||||||
|
String(26),
|
||||||
|
ForeignKey('users.id', ondelete='RESTRICT'),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
usdt_exchange_rate: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
gas_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
total_price: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
service_fee: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
status: Mapped[OrderStatus] = mapped_column(
|
||||||
|
SAEnum(OrderStatus,name='order_status_enum',values_callable=lambda x:[e.value for e in x]),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
default=OrderStatus.PENDING,
|
||||||
|
)
|
||||||
|
|
||||||
|
client_payment_id: Mapped[str] = mapped_column(
|
||||||
|
String(26),
|
||||||
|
nullable=False,
|
||||||
|
unique=True,
|
||||||
|
index=True
|
||||||
|
)
|
||||||
|
|
||||||
|
itpay_payment_qr_url_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_url_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_url_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
itpay_payment_qr_image_desktop: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_image_android: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
itpay_payment_qr_image_ios: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|
||||||
|
itpay_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||||
|
itpay_qr_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||||
|
itpay_amount: Mapped[Decimal] = mapped_column(Numeric(38, 2), nullable=False)
|
||||||
|
itpay_created_at: Mapped[DateTime] = mapped_column(
|
||||||
|
DateTime(timezone=True),
|
||||||
|
nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
@@ -20,8 +20,8 @@ class PurchaseRequestModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
|||||||
index=True,
|
index=True,
|
||||||
)
|
)
|
||||||
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
|
status: Mapped[str] = mapped_column(String(32), nullable=False, server_default='submitted', default='submitted')
|
||||||
usdt_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
rub_amount: Mapped[Decimal] = mapped_column(Numeric(18, 8), nullable=False)
|
||||||
rub_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
usdt_amount: Mapped[Decimal | None] = mapped_column(Numeric(18, 2), nullable=True)
|
||||||
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
exchange_rate: Mapped[Decimal | None] = mapped_column(Numeric(18, 8), nullable=True)
|
||||||
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
service_fee_percent: Mapped[Decimal | None] = mapped_column(Numeric(5, 2), nullable=True)
|
||||||
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
comment: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||||
|
|||||||
32
src/infrastructure/database/models/risk.py
Normal file
32
src/infrastructure/database/models/risk.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import DateTime, ForeignKey, Integer, String, func, text
|
||||||
|
from sqlalchemy.dialects.postgresql import JSONB
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from src.infrastructure.database.models.base import Base
|
||||||
|
from src.infrastructure.database.models.mixins import AuditTimestampsMixin, UlidPrimaryKeyMixin
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin):
|
||||||
|
__tablename__ = "risk_assessments"
|
||||||
|
|
||||||
|
user_id: Mapped[str] = mapped_column(String(26), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False, index=True)
|
||||||
|
order_id: Mapped[str | None] = mapped_column(String(26), ForeignKey("orders.id", ondelete="SET NULL"), nullable=True, index=True)
|
||||||
|
subject_type: Mapped[str] = mapped_column(String(32), nullable=False, default="individual", server_default="individual", index=True)
|
||||||
|
score: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
||||||
|
decision: Mapped[str] = mapped_column(String(32), nullable=False, default="allow", index=True)
|
||||||
|
reasons: Mapped[list[str]] = mapped_column(JSONB, nullable=False, default=list, server_default=text("'[]'::jsonb"))
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventModel(Base, UlidPrimaryKeyMixin):
|
||||||
|
__tablename__ = "audit_events"
|
||||||
|
|
||||||
|
actor_type: Mapped[str] = mapped_column(String(32), nullable=False, default="system", server_default="system", index=True)
|
||||||
|
actor_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
action: Mapped[str] = mapped_column(String(128), nullable=False, index=True)
|
||||||
|
entity_type: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||||
|
entity_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||||
|
severity: Mapped[str] = mapped_column(String(32), nullable=False, default="info", server_default="info", index=True)
|
||||||
|
metadata_json: Mapped[dict] = mapped_column("metadata", JSONB, nullable=False, default=dict, server_default=text("'{}'::jsonb"))
|
||||||
|
created_at: Mapped[DateTime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now())
|
||||||
@@ -5,6 +5,8 @@ from src.infrastructure.database.repositories.legal_entity_repository import Leg
|
|||||||
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.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',
|
||||||
@@ -14,4 +16,5 @@ __all__ = [
|
|||||||
'OrganizationWalletRepository',
|
'OrganizationWalletRepository',
|
||||||
'PurchaseRequestRepository',
|
'PurchaseRequestRepository',
|
||||||
'WalletRepository',
|
'WalletRepository',
|
||||||
|
'OrderRepository',
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from datetime import datetime
|
|||||||
from fastapi import status
|
from fastapi import status
|
||||||
from sqlalchemy import func, select, update
|
from sqlalchemy import func, select, update
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
from sqlalchemy.exc import SQLAlchemyError
|
||||||
|
|
||||||
from src.application.abstractions.repositories import IAdminUserRepository
|
from src.application.abstractions.repositories import IAdminUserRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
|
|||||||
220
src/infrastructure/database/repositories/orders_repository.py
Normal file
220
src/infrastructure/database/repositories/orders_repository.py
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
from dataclasses import replace
|
||||||
|
from decimal import Decimal
|
||||||
|
from sqlalchemy import desc, select, or_, extract, func
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
from src.application.abstractions.repositories.i_order_repository import IOrderRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.order import OrderEntity
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
|
||||||
|
|
||||||
|
class OrderRepository(IOrderRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _to_entity(model: OrderModel) -> OrderEntity:
|
||||||
|
return OrderEntity(
|
||||||
|
id=model.id,
|
||||||
|
created_at=model.created_at,
|
||||||
|
updated_at=model.updated_at,
|
||||||
|
user_id=model.user_id,
|
||||||
|
usdt_amount=model.usdt_amount,
|
||||||
|
usdt_exchange_rate=model.usdt_exchange_rate,
|
||||||
|
gas_fee=model.gas_fee,
|
||||||
|
total_price=model.total_price,
|
||||||
|
service_fee=model.service_fee,
|
||||||
|
status=model.status,
|
||||||
|
client_payment_id=model.client_payment_id,
|
||||||
|
itpay_payment_qr_url_desktop=model.itpay_payment_qr_url_desktop,
|
||||||
|
itpay_payment_qr_url_android=model.itpay_payment_qr_url_android,
|
||||||
|
itpay_payment_qr_url_ios=model.itpay_payment_qr_url_ios,
|
||||||
|
itpay_payment_qr_image_desktop=model.itpay_payment_qr_image_desktop,
|
||||||
|
itpay_payment_qr_image_android=model.itpay_payment_qr_image_android,
|
||||||
|
itpay_payment_qr_image_ios=model.itpay_payment_qr_image_ios,
|
||||||
|
itpay_id=model.itpay_id,
|
||||||
|
itpay_qr_id=model.itpay_qr_id,
|
||||||
|
itpay_amount=model.itpay_amount,
|
||||||
|
itpay_created_at=model.itpay_created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _search_filter(self, search: str | None):
|
||||||
|
if not search or not search.strip():
|
||||||
|
return None
|
||||||
|
|
||||||
|
pattern = f'%{search.strip()}%'
|
||||||
|
return or_(
|
||||||
|
OrderModel.id.ilike(pattern),
|
||||||
|
OrderModel.user_id.ilike(pattern),
|
||||||
|
OrderModel.status.ilike(pattern),
|
||||||
|
OrderModel.client_payment_id.ilike(pattern),
|
||||||
|
OrderModel.itpay_id.ilike(pattern),
|
||||||
|
OrderModel.itpay_qr_id.ilike(pattern),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def get_by_id(self,order_id: str) -> OrderEntity | None:
|
||||||
|
stmt=select(OrderModel).where(OrderModel.id==order_id)
|
||||||
|
model=await self._session.scalar(stmt)
|
||||||
|
if model is None:
|
||||||
|
return None
|
||||||
|
return self._to_entity(model)
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_user_id(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
user_id: str,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
cursor_created_at=None
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.where(OrderModel.user_id==user_id)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
result=await self._session.scalars(stmt)
|
||||||
|
return [self._to_entity(model) for model in result.all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_all(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
search: str | None = None,
|
||||||
|
cursor_created_at=None
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return [self._to_entity(model) for model in res.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def list_by_date(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
limit: int,
|
||||||
|
offset: int,
|
||||||
|
cursor_created_at=None,
|
||||||
|
) -> list[OrderEntity]:
|
||||||
|
stmt=(
|
||||||
|
select(OrderModel)
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
)
|
||||||
|
.order_by(desc(OrderModel.created_at))
|
||||||
|
.limit(limit)
|
||||||
|
)
|
||||||
|
if cursor_created_at is not None:
|
||||||
|
stmt = stmt.where(OrderModel.created_at < cursor_created_at)
|
||||||
|
else:
|
||||||
|
stmt = stmt.offset(offset)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return [self._to_entity(model) for model in res.scalars().all()]
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_by_month(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> Decimal:
|
||||||
|
stmt = (
|
||||||
|
select(func.coalesce(func.sum(OrderModel.service_fee), 0))
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
OrderModel.status == OrderStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return res.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def sum_by_day(
|
||||||
|
self,
|
||||||
|
*,
|
||||||
|
year: int,
|
||||||
|
month: int,
|
||||||
|
day: int,
|
||||||
|
search: str | None = None,
|
||||||
|
) -> Decimal:
|
||||||
|
stmt = (
|
||||||
|
select(func.coalesce(func.sum(OrderModel.service_fee), 0))
|
||||||
|
.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
extract("day", OrderModel.created_at) == day,
|
||||||
|
OrderModel.status == OrderStatus.COMPLETED.value,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return res.scalar_one()
|
||||||
|
|
||||||
|
|
||||||
|
async def count_all(self,*,search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_by_month(self,*,year: int,month: int, search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
stmt = stmt.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
async def count_by_day(self,*,year: int,month: int,day: int, search: str | None = None) -> int:
|
||||||
|
stmt = select(func.count()).select_from(OrderModel)
|
||||||
|
stmt = stmt.where(
|
||||||
|
extract("year", OrderModel.created_at) == year,
|
||||||
|
extract("month", OrderModel.created_at) == month,
|
||||||
|
extract("day", OrderModel.created_at) == day
|
||||||
|
)
|
||||||
|
search_filter = self._search_filter(search)
|
||||||
|
if search_filter is not None:
|
||||||
|
stmt = stmt.where(search_filter)
|
||||||
|
res = await self._session.execute(stmt)
|
||||||
|
return int(res.scalar_one())
|
||||||
@@ -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())
|
||||||
|
|||||||
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
111
src/infrastructure/database/repositories/risk_repository.py
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import Integer, func, select, text
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from src.application.abstractions.repositories.i_risk_repository import IRiskRepository
|
||||||
|
from src.application.contracts import ILogger
|
||||||
|
from src.application.domain.entities.risk import AuditEventEntity, FunnelEntity, RiskAssessmentEntity, RiskSummaryEntity
|
||||||
|
from src.infrastructure.database.models.order import OrderModel
|
||||||
|
from src.infrastructure.database.models.risk import AuditEventModel, RiskAssessmentModel
|
||||||
|
from src.infrastructure.database.models.user import UserModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskRepository(IRiskRepository):
|
||||||
|
def __init__(self, session: AsyncSession, logger: ILogger):
|
||||||
|
self._session = session
|
||||||
|
self._logger = logger
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _assessment(model: RiskAssessmentModel) -> RiskAssessmentEntity:
|
||||||
|
return RiskAssessmentEntity(
|
||||||
|
id=model.id,
|
||||||
|
user_id=model.user_id,
|
||||||
|
order_id=model.order_id,
|
||||||
|
subject_type=model.subject_type,
|
||||||
|
score=model.score,
|
||||||
|
decision=model.decision,
|
||||||
|
reasons=list(model.reasons or []),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _event(model: AuditEventModel) -> AuditEventEntity:
|
||||||
|
return AuditEventEntity(
|
||||||
|
id=model.id,
|
||||||
|
actor_type=model.actor_type,
|
||||||
|
actor_id=model.actor_id,
|
||||||
|
action=model.action,
|
||||||
|
entity_type=model.entity_type,
|
||||||
|
entity_id=model.entity_id,
|
||||||
|
severity=model.severity,
|
||||||
|
metadata=dict(model.metadata_json or {}),
|
||||||
|
created_at=model.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_assessments(self, *, limit: int, offset: int, decision: str | None, user_id: str | None, order_id: str | None) -> tuple[list[RiskAssessmentEntity], int]:
|
||||||
|
stmt = select(RiskAssessmentModel).order_by(RiskAssessmentModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(RiskAssessmentModel)
|
||||||
|
filters = []
|
||||||
|
if decision:
|
||||||
|
filters.append(RiskAssessmentModel.decision == decision)
|
||||||
|
if user_id:
|
||||||
|
filters.append(RiskAssessmentModel.user_id == user_id)
|
||||||
|
if order_id:
|
||||||
|
filters.append(RiskAssessmentModel.order_id == order_id)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._assessment(row) for row in rows], total
|
||||||
|
|
||||||
|
async def summary(self) -> RiskSummaryEntity:
|
||||||
|
stmt = select(
|
||||||
|
func.count(),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "allow").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "manual_review").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.decision == "reject").cast(Integer)), 0),
|
||||||
|
func.coalesce(func.avg(RiskAssessmentModel.score), 0),
|
||||||
|
func.coalesce(func.sum((RiskAssessmentModel.score >= 60).cast(Integer)), 0),
|
||||||
|
)
|
||||||
|
row = (await self._session.execute(stmt)).one()
|
||||||
|
return RiskSummaryEntity(
|
||||||
|
total=int(row[0] or 0),
|
||||||
|
allow=int(row[1] or 0),
|
||||||
|
manual_review=int(row[2] or 0),
|
||||||
|
reject=int(row[3] or 0),
|
||||||
|
average_score=float(row[4] or 0),
|
||||||
|
high_risk=int(row[5] or 0),
|
||||||
|
)
|
||||||
|
|
||||||
|
async def list_audit_events(self, *, limit: int, offset: int, severity: str | None, action: str | None, entity_type: str | None) -> tuple[list[AuditEventEntity], int]:
|
||||||
|
stmt = select(AuditEventModel).order_by(AuditEventModel.created_at.desc()).limit(limit).offset(offset)
|
||||||
|
count_stmt = select(func.count()).select_from(AuditEventModel)
|
||||||
|
filters = []
|
||||||
|
if severity:
|
||||||
|
filters.append(AuditEventModel.severity == severity)
|
||||||
|
if action:
|
||||||
|
filters.append(AuditEventModel.action == action)
|
||||||
|
if entity_type:
|
||||||
|
filters.append(AuditEventModel.entity_type == entity_type)
|
||||||
|
if filters:
|
||||||
|
stmt = stmt.where(*filters)
|
||||||
|
count_stmt = count_stmt.where(*filters)
|
||||||
|
rows = (await self._session.scalars(stmt)).all()
|
||||||
|
total = int((await self._session.execute(count_stmt)).scalar_one())
|
||||||
|
return [self._event(row) for row in rows], total
|
||||||
|
|
||||||
|
async def funnel(self) -> FunnelEntity:
|
||||||
|
registrations = int((await self._session.execute(select(func.count()).select_from(UserModel))).scalar_one())
|
||||||
|
kyc_started = int((await self._session.execute(text("select count(distinct user_id) from kyc"))).scalar_one())
|
||||||
|
kyc_completed = int((await self._session.execute(select(func.count()).select_from(UserModel).where(UserModel.kyc_verified.is_(True)))).scalar_one())
|
||||||
|
first_payment = int((await self._session.execute(select(func.count(func.distinct(OrderModel.user_id))))).scalar_one())
|
||||||
|
successful_operations = int((await self._session.execute(text("select count(*) from payments where status = 'completed'"))).scalar_one())
|
||||||
|
return FunnelEntity(
|
||||||
|
registrations=registrations,
|
||||||
|
kyc_started=kyc_started,
|
||||||
|
kyc_completed=kyc_completed,
|
||||||
|
first_payment=first_payment,
|
||||||
|
successful_operations=successful_operations,
|
||||||
|
)
|
||||||
@@ -2,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
|
||||||
@@ -157,14 +156,18 @@ class UserRepository(IUserRepository):
|
|||||||
limit: int,
|
limit: int,
|
||||||
offset: int,
|
offset: int,
|
||||||
search: str | None = None,
|
search: str | None = None,
|
||||||
|
cursor_created_at=None,
|
||||||
) -> list[UserEntity]:
|
) -> list[UserEntity]:
|
||||||
stmt = (
|
stmt = (
|
||||||
select(UserModel)
|
select(UserModel)
|
||||||
.where(UserModel.is_deleted.is_(False))
|
.where(UserModel.is_deleted.is_(False))
|
||||||
.order_by(UserModel.created_at.desc())
|
.order_by(UserModel.created_at.desc())
|
||||||
.limit(limit)
|
.limit(limit)
|
||||||
.offset(offset)
|
|
||||||
)
|
)
|
||||||
|
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)
|
search_filter = self._search_filter(search)
|
||||||
if search_filter is not None:
|
if search_filter is not None:
|
||||||
stmt = stmt.where(search_filter)
|
stmt = stmt.where(search_filter)
|
||||||
|
|||||||
@@ -2,12 +2,10 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
from sqlalchemy.exc import IntegrityError, SQLAlchemyError
|
|
||||||
|
|
||||||
from src.application.abstractions.repositories import IWalletRepository
|
from src.application.abstractions.repositories import IWalletRepository
|
||||||
from src.application.contracts import ILogger
|
from src.application.contracts import ILogger
|
||||||
from src.application.domain.entities.user import WalletEntity
|
from src.application.domain.entities.user import WalletEntity
|
||||||
from src.application.domain.exceptions import ApplicationException
|
|
||||||
from src.infrastructure.database.models import WalletModel
|
from src.infrastructure.database.models import WalletModel
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ from src.application.abstractions.repositories import (
|
|||||||
IPurchaseRequestRepository,
|
IPurchaseRequestRepository,
|
||||||
IUserRepository,
|
IUserRepository,
|
||||||
IWalletRepository,
|
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
|
||||||
@@ -20,6 +22,8 @@ from src.infrastructure.database.repositories import (
|
|||||||
PurchaseRequestRepository,
|
PurchaseRequestRepository,
|
||||||
UserRepository,
|
UserRepository,
|
||||||
WalletRepository,
|
WalletRepository,
|
||||||
|
OrderRepository,
|
||||||
|
RiskRepository,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -34,10 +38,13 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
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._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):
|
||||||
@@ -103,3 +110,17 @@ class UnitOfWork(IUnitOfWork):
|
|||||||
session=self._session, logger=self._logger
|
session=self._session, logger=self._logger
|
||||||
)
|
)
|
||||||
return self._purchase_request_repository
|
return self._purchase_request_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def order_repository(self) -> IOrderRepository:
|
||||||
|
if self._order_repository is None:
|
||||||
|
self._order_repository = OrderRepository(
|
||||||
|
session=self._session, logger=self._logger
|
||||||
|
)
|
||||||
|
return self._order_repository
|
||||||
|
|
||||||
|
@property
|
||||||
|
def risk_repository(self) -> IRiskRepository:
|
||||||
|
if self._risk_repository is None:
|
||||||
|
self._risk_repository = RiskRepository(session=self._session, logger=self._logger)
|
||||||
|
return self._risk_repository
|
||||||
|
|||||||
@@ -9,9 +9,7 @@ class HashService(IHashService):
|
|||||||
|
|
||||||
async def hash(self, value: str) -> str:
|
async def hash(self, value: str) -> str:
|
||||||
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
|
hashed_value = bcrypt.hashpw(value.encode(), bcrypt.gensalt())
|
||||||
self._logger.info(f'Hash value {hashed_value.decode()}')
|
|
||||||
return hashed_value.decode()
|
return hashed_value.decode()
|
||||||
|
|
||||||
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
async def verify(self, hashed_value: str, plain_value: str) -> bool:
|
||||||
self._logger.info(f'Hash value {hashed_value[:10]}')
|
|
||||||
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())
|
return bcrypt.checkpw(plain_value.encode(), hashed_value.encode())
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -32,6 +32,20 @@ from src.application.commands import (
|
|||||||
ListUserWalletsCommand,
|
ListUserWalletsCommand,
|
||||||
GetUserMnemonicCommand,
|
GetUserMnemonicCommand,
|
||||||
GetUserSecretKeysCommand,
|
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
|
||||||
@@ -250,3 +264,87 @@ def get_get_user_secret_keys_command(
|
|||||||
logger: ILogger = Depends(get_logger),
|
logger: ILogger = Depends(get_logger),
|
||||||
) -> GetUserSecretKeysCommand:
|
) -> GetUserSecretKeysCommand:
|
||||||
return GetUserSecretKeysCommand(uow, logger)
|
return GetUserSecretKeysCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_orders_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListOrdersCommand:
|
||||||
|
return ListOrdersCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_user_orders_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListUserOrdersCommand:
|
||||||
|
return ListUserOrdersCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_list_orders_by_date_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> ListOrdersByDateCommand:
|
||||||
|
return ListOrdersByDateCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_order_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrderCommand:
|
||||||
|
return GetOrderCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_year_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForYearCommand:
|
||||||
|
return GetPurchaseAnalyticsForYearCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_year_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForYearCommand:
|
||||||
|
return GetOrdersAnalyticsForYearCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_month_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForMonthCommand:
|
||||||
|
return GetPurchaseAnalyticsForMonthCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_month_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForMonthCommand:
|
||||||
|
return GetOrdersAnalyticsForMonthCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_purchase_analytics_for_week_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetPurchaseAnalyticsForWeekCommand:
|
||||||
|
return GetPurchaseAnalyticsForWeekCommand(uow, logger)
|
||||||
|
|
||||||
|
def get_get_orders_analytics_for_week_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
logger: ILogger = Depends(get_logger),
|
||||||
|
) -> GetOrdersAnalyticsForWeekCommand:
|
||||||
|
return GetOrdersAnalyticsForWeekCommand(uow, logger)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_risk_assessments_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListRiskAssessmentsCommand:
|
||||||
|
return ListRiskAssessmentsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_risk_summary_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetRiskSummaryCommand:
|
||||||
|
return GetRiskSummaryCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_list_audit_events_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> ListAuditEventsCommand:
|
||||||
|
return ListAuditEventsCommand(uow)
|
||||||
|
|
||||||
|
|
||||||
|
def get_get_funnel_command(
|
||||||
|
uow: IUnitOfWork = Depends(get_unit_of_work),
|
||||||
|
) -> GetFunnelCommand:
|
||||||
|
return GetFunnelCommand(uow)
|
||||||
@@ -6,6 +6,10 @@ from src.presentation.routing.jwt import jwt_router
|
|||||||
from src.presentation.routing.organizations import organizations_router
|
from src.presentation.routing.organizations import organizations_router
|
||||||
from src.presentation.routing.purchase_requests import purchase_requests_router
|
from src.presentation.routing.purchase_requests import purchase_requests_router
|
||||||
from src.presentation.routing.users import users_router
|
from src.presentation.routing.users import users_router
|
||||||
|
from src.presentation.routing.search import search_router
|
||||||
|
from src.presentation.routing.orders import orders_router
|
||||||
|
from src.presentation.routing.analytics import analytics_router
|
||||||
|
from src.presentation.routing.risk import audit_router, risk_router
|
||||||
|
|
||||||
v1_router = APIRouter(prefix='/v1')
|
v1_router = APIRouter(prefix='/v1')
|
||||||
v1_router.include_router(auth_router)
|
v1_router.include_router(auth_router)
|
||||||
@@ -14,3 +18,8 @@ v1_router.include_router(organizations_router)
|
|||||||
v1_router.include_router(documents_router)
|
v1_router.include_router(documents_router)
|
||||||
v1_router.include_router(purchase_requests_router)
|
v1_router.include_router(purchase_requests_router)
|
||||||
v1_router.include_router(users_router)
|
v1_router.include_router(users_router)
|
||||||
|
v1_router.include_router(search_router)
|
||||||
|
v1_router.include_router(orders_router)
|
||||||
|
v1_router.include_router(analytics_router)
|
||||||
|
v1_router.include_router(risk_router)
|
||||||
|
v1_router.include_router(audit_router)
|
||||||
172
src/presentation/routing/analytics.py
Normal file
172
src/presentation/routing/analytics.py
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Request, Query, status
|
||||||
|
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_get_purchase_analytics_for_year_command,
|
||||||
|
get_get_orders_analytics_for_year_command,
|
||||||
|
get_get_purchase_analytics_for_month_command,
|
||||||
|
get_get_orders_analytics_for_month_command,
|
||||||
|
get_get_purchase_analytics_for_week_command,
|
||||||
|
get_get_orders_analytics_for_week_command,
|
||||||
|
get_get_funnel_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.analytics import (
|
||||||
|
OrderAnalyticsResponse,
|
||||||
|
PurchaseRequestAnalyticsResponse,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.risk import FunnelResponse
|
||||||
|
from src.application.commands.analytics_commands import (
|
||||||
|
GetPurchaseAnalyticsForYearCommand,
|
||||||
|
GetOrdersAnalyticsForYearCommand,
|
||||||
|
GetPurchaseAnalyticsForMonthCommand,
|
||||||
|
GetOrdersAnalyticsForMonthCommand,
|
||||||
|
GetPurchaseAnalyticsForWeekCommand,
|
||||||
|
GetOrdersAnalyticsForWeekCommand,
|
||||||
|
)
|
||||||
|
from src.application.commands.risk_commands import GetFunnelCommand
|
||||||
|
|
||||||
|
analytics_router = APIRouter(prefix='/analytics', tags=['analytics'])
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/yearly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_year(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForYearCommand = Depends(get_get_purchase_analytics_for_year_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/yearly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_year(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForYearCommand = Depends(get_get_orders_analytics_for_year_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/monthly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_month(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForMonthCommand = Depends(get_get_purchase_analytics_for_month_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/monthly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_month(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForMonthCommand = Depends(get_get_orders_analytics_for_month_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/purchases/weekly', response_model=PurchaseRequestAnalyticsResponse)
|
||||||
|
async def get_purchase_analytics_for_week(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
status: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
organization_id: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetPurchaseAnalyticsForWeekCommand = Depends(get_get_purchase_analytics_for_week_command),
|
||||||
|
):
|
||||||
|
items, total, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
status=status,
|
||||||
|
organization_id=organization_id,
|
||||||
|
)
|
||||||
|
return PurchaseRequestAnalyticsResponse(
|
||||||
|
requests_count=items,
|
||||||
|
total_requests=total,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
@analytics_router.get('/orders/weekly', response_model=OrderAnalyticsResponse)
|
||||||
|
async def get_orders_analytics_for_week(
|
||||||
|
start_year: int = Query(..., ge=2000, le=2100),
|
||||||
|
start_month: int = Query(..., ge=1, le=12),
|
||||||
|
start_day: int = Query(..., ge=1, le=31),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
command: GetOrdersAnalyticsForWeekCommand = Depends(get_get_orders_analytics_for_week_command),
|
||||||
|
):
|
||||||
|
orders_count, orders_sum, total_orders, total_sum, period = await command(
|
||||||
|
start_year=start_year,
|
||||||
|
start_month=start_month,
|
||||||
|
start_day=start_day,
|
||||||
|
search=q,
|
||||||
|
)
|
||||||
|
return OrderAnalyticsResponse(
|
||||||
|
orders_count=orders_count,
|
||||||
|
summed_service_fees=orders_sum,
|
||||||
|
total_orders=total_orders,
|
||||||
|
total_summed_service_fees=total_sum,
|
||||||
|
period=period,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@analytics_router.get('/funnel', response_model=FunnelResponse)
|
||||||
|
async def get_funnel(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetFunnelCommand = Depends(get_get_funnel_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return FunnelResponse(**item.__dict__)
|
||||||
90
src/presentation/routing/orders.py
Normal file
90
src/presentation/routing/orders.py
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access, require_admin_role
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_list_orders_command,
|
||||||
|
get_list_orders_by_date_command,
|
||||||
|
get_get_order_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.mappers import order_to_response
|
||||||
|
from src.presentation.schemas.order import (
|
||||||
|
OrdersResponse,
|
||||||
|
OrdersByDateResponse,
|
||||||
|
OrderDetailResponse,
|
||||||
|
)
|
||||||
|
from src.application.commands.orders_commands import (
|
||||||
|
ListOrdersCommand,
|
||||||
|
ListOrdersByDateCommand,
|
||||||
|
GetOrderCommand,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
orders_router = APIRouter(prefix='/orders', tags=['orders'])
|
||||||
|
|
||||||
|
@orders_router.get('', response_model=OrdersResponse)
|
||||||
|
async def list_orders(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListOrdersCommand = Depends(get_list_orders_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
|
||||||
|
return OrdersResponse(
|
||||||
|
items=[order_to_response(x) for x in items],
|
||||||
|
total=total,
|
||||||
|
next_cursor=items[-1].created_at.isoformat() if items and items[-1].created_at else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@orders_router.get('/{order_id}', response_model=OrderDetailResponse)
|
||||||
|
async def get_order(
|
||||||
|
order_id: str,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetOrderCommand = Depends(get_get_order_command),
|
||||||
|
):
|
||||||
|
order = await command(order_id=order_id)
|
||||||
|
return OrderDetailResponse(
|
||||||
|
id=order.id,
|
||||||
|
created_at=order.created_at.isoformat() if order.created_at is not None else None,
|
||||||
|
updated_at=order.updated_at.isoformat() if order.updated_at is not None else None,
|
||||||
|
user_id=order.user_id,
|
||||||
|
usdt_amount=str(order.usdt_amount) if order.usdt_amount is not None else None,
|
||||||
|
usdt_exchange_rate=str(order.usdt_exchange_rate) if order.usdt_exchange_rate is not None else None,
|
||||||
|
gas_fee=str(order.gas_fee) if order.gas_fee is not None else None,
|
||||||
|
total_price=str(order.total_price) if order.total_price is not None else None,
|
||||||
|
service_fee=str(order.service_fee) if order.service_fee is not None else None,
|
||||||
|
status=order.status,
|
||||||
|
client_payment_id=order.client_payment_id,
|
||||||
|
itpay_payment_qr_url_desktop=order.itpay_payment_qr_url_desktop,
|
||||||
|
itpay_payment_qr_url_android=order.itpay_payment_qr_url_android,
|
||||||
|
itpay_payment_qr_url_ios=order.itpay_payment_qr_url_ios,
|
||||||
|
itpay_payment_qr_image_desktop=order.itpay_payment_qr_image_desktop,
|
||||||
|
itpay_payment_qr_image_android=order.itpay_payment_qr_image_android,
|
||||||
|
itpay_payment_qr_image_ios=order.itpay_payment_qr_image_ios,
|
||||||
|
itpay_id=order.itpay_id,
|
||||||
|
itpay_qr_id=order.itpay_qr_id,
|
||||||
|
itpay_amount=str(order.itpay_amount) if order.itpay_amount is not None else None,
|
||||||
|
itpay_created_at=order.itpay_created_at.isoformat() if order.itpay_created_at is not None else None,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@orders_router.get('/filter/date', response_model=OrdersByDateResponse)
|
||||||
|
async def list_orders_by_date(
|
||||||
|
year: int = Query(..., ge=2000, le=2100),
|
||||||
|
month: int = Query(..., ge=1, le=12),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListOrdersByDateCommand = Depends(get_list_orders_by_date_command),
|
||||||
|
):
|
||||||
|
orders, total, summary = await command(year=year, month=month, limit=limit, offset=offset, cursor=cursor)
|
||||||
|
return OrdersByDateResponse(
|
||||||
|
items=[order_to_response(o) for o in orders],
|
||||||
|
total=total,
|
||||||
|
summed_service_fees=summary,
|
||||||
|
next_cursor=orders[-1].created_at.isoformat() if orders and orders[-1].created_at else None,
|
||||||
|
)
|
||||||
@@ -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,31 +20,24 @@ 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,
|
||||||
OrganizationListResponse,
|
OrganizationListResponse,
|
||||||
DocumentResponse,
|
|
||||||
OrganizationResponse,
|
OrganizationResponse,
|
||||||
UpdateOrganizationRequest,
|
UpdateOrganizationRequest,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.entity import (
|
from src.presentation.schemas.entity import (
|
||||||
CreateWalletsResponse,
|
CreateWalletsResponse,
|
||||||
MnemonicResponse,
|
MnemonicResponse,
|
||||||
PartySearchResponse,
|
|
||||||
PartySearchListResponse,
|
|
||||||
PurchaseRequestResponse,
|
|
||||||
PurchaseRequestListResponse,
|
|
||||||
SecretKeyResponse,
|
SecretKeyResponse,
|
||||||
WalletResponse,
|
WalletResponse,
|
||||||
)
|
)
|
||||||
@@ -93,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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
80
src/presentation/routing/risk.py
Normal file
80
src/presentation/routing/risk.py
Normal file
@@ -0,0 +1,80 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.commands.risk_commands import GetRiskSummaryCommand, ListAuditEventsCommand, ListRiskAssessmentsCommand
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import get_get_risk_summary_command, get_list_audit_events_command, get_list_risk_assessments_command
|
||||||
|
from src.presentation.schemas.risk import AuditEventResponse, AuditEventsResponse, RiskAssessmentResponse, RiskAssessmentsResponse, RiskSummaryResponse
|
||||||
|
|
||||||
|
risk_router = APIRouter(prefix="/risk", tags=["risk"])
|
||||||
|
audit_router = APIRouter(prefix="/audit", tags=["audit"])
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/assessments", response_model=RiskAssessmentsResponse)
|
||||||
|
async def list_risk_assessments(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
decision: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
user_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
order_id: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListRiskAssessmentsCommand = Depends(get_list_risk_assessments_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, decision=decision, user_id=user_id, order_id=order_id)
|
||||||
|
return RiskAssessmentsResponse(
|
||||||
|
items=[
|
||||||
|
RiskAssessmentResponse(
|
||||||
|
id=x.id,
|
||||||
|
user_id=x.user_id,
|
||||||
|
order_id=x.order_id,
|
||||||
|
subject_type=x.subject_type,
|
||||||
|
score=x.score,
|
||||||
|
decision=x.decision,
|
||||||
|
reasons=x.reasons,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@risk_router.get("/summary", response_model=RiskSummaryResponse)
|
||||||
|
async def get_risk_summary(
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: GetRiskSummaryCommand = Depends(get_get_risk_summary_command),
|
||||||
|
):
|
||||||
|
item = await command()
|
||||||
|
return RiskSummaryResponse(**item.__dict__)
|
||||||
|
|
||||||
|
|
||||||
|
@audit_router.get("/events", response_model=AuditEventsResponse)
|
||||||
|
async def list_audit_events(
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
severity: str | None = Query(default=None, min_length=1, max_length=32),
|
||||||
|
action: str | None = Query(default=None, min_length=1, max_length=128),
|
||||||
|
entity_type: str | None = Query(default=None, min_length=1, max_length=64),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: ListAuditEventsCommand = Depends(get_list_audit_events_command),
|
||||||
|
):
|
||||||
|
items, total = await command(limit=limit, offset=offset, severity=severity, action=action, entity_type=entity_type)
|
||||||
|
return AuditEventsResponse(
|
||||||
|
items=[
|
||||||
|
AuditEventResponse(
|
||||||
|
id=x.id,
|
||||||
|
actor_type=x.actor_type,
|
||||||
|
actor_id=x.actor_id,
|
||||||
|
action=x.action,
|
||||||
|
entity_type=x.entity_type,
|
||||||
|
entity_id=x.entity_id,
|
||||||
|
severity=x.severity,
|
||||||
|
metadata=x.metadata,
|
||||||
|
created_at=x.created_at.isoformat() if x.created_at else None,
|
||||||
|
)
|
||||||
|
for x in items
|
||||||
|
],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
32
src/presentation/routing/search.py
Normal file
32
src/presentation/routing/search.py
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
from fastapi import APIRouter, Depends, Query
|
||||||
|
|
||||||
|
from src.application.commands import (
|
||||||
|
SearchPartiesCommand,
|
||||||
|
)
|
||||||
|
from src.application.domain.dto import AdminAuthContext
|
||||||
|
from src.presentation.decorators.admin_auth import require_admin_access
|
||||||
|
from src.presentation.dependencies.commands import (
|
||||||
|
get_search_parties_command,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.mappers import (
|
||||||
|
party_search_to_response,
|
||||||
|
)
|
||||||
|
from src.presentation.schemas.entity import (
|
||||||
|
PartySearchListResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
search_router = APIRouter(prefix='/search', tags=['search'])
|
||||||
|
|
||||||
|
@search_router.get('', response_model=PartySearchListResponse)
|
||||||
|
async def search_parties(
|
||||||
|
q: str = Query(min_length=1, max_length=255),
|
||||||
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
|
offset: int = Query(default=0, ge=0),
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
|
command: SearchPartiesCommand = Depends(get_search_parties_command),
|
||||||
|
):
|
||||||
|
items, total = await command(query=q, limit=limit, offset=offset)
|
||||||
|
return PartySearchListResponse(
|
||||||
|
items=[party_search_to_response(x) for x in items],
|
||||||
|
total=total,
|
||||||
|
)
|
||||||
@@ -11,25 +11,27 @@ from src.presentation.dependencies.commands import (
|
|||||||
get_list_user_wallets_command,
|
get_list_user_wallets_command,
|
||||||
get_get_user_mnemonic_command,
|
get_get_user_mnemonic_command,
|
||||||
get_get_user_secret_keys_command,
|
get_get_user_secret_keys_command,
|
||||||
|
get_list_user_orders_command,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.mappers import (
|
from src.presentation.schemas.mappers import (
|
||||||
mnemonic_to_response,
|
mnemonic_to_response,
|
||||||
user_to_response,
|
user_to_response,
|
||||||
party_search_to_response,
|
|
||||||
secret_key_to_response,
|
secret_key_to_response,
|
||||||
wallet_to_response,
|
wallet_to_response,
|
||||||
|
order_to_response,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.user import (
|
from src.presentation.schemas.user import (
|
||||||
UserResponse,
|
UserResponse,
|
||||||
UserListResponse,
|
UserListResponse,
|
||||||
)
|
)
|
||||||
from src.presentation.schemas.entity import (
|
from src.presentation.schemas.entity import (
|
||||||
PartySearchResponse,
|
|
||||||
MnemonicResponse,
|
MnemonicResponse,
|
||||||
PartySearchListResponse,
|
|
||||||
SecretKeyResponse,
|
SecretKeyResponse,
|
||||||
WalletResponse,
|
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 src.application.commands.user_commands import (
|
from src.application.commands.user_commands import (
|
||||||
ListUsersCommand,
|
ListUsersCommand,
|
||||||
@@ -40,22 +42,28 @@ from src.application.commands.user_wallet_commands import (
|
|||||||
GetUserMnemonicCommand,
|
GetUserMnemonicCommand,
|
||||||
GetUserSecretKeysCommand
|
GetUserSecretKeysCommand
|
||||||
)
|
)
|
||||||
|
from src.application.commands.orders_commands import (
|
||||||
|
ListUserOrdersCommand,
|
||||||
|
)
|
||||||
from src.presentation.schemas.password import SetPasswordRequest
|
from src.presentation.schemas.password import SetPasswordRequest
|
||||||
|
|
||||||
|
|
||||||
users_router = APIRouter(prefix='/users', tags=['users'])
|
users_router = APIRouter(prefix='/users', tags=['users'])
|
||||||
|
|
||||||
@users_router.get('', response_model=UserListResponse)
|
@users_router.get('', response_model=UserListResponse)
|
||||||
async def list_users(
|
async def list_users(
|
||||||
limit: int = Query(default=50, ge=1, le=200),
|
limit: int = Query(default=50, ge=1, le=200),
|
||||||
offset: int = Query(default=0, ge=0),
|
offset: int = Query(default=0, ge=0),
|
||||||
|
cursor: str | None = Query(default=None),
|
||||||
q: str | None = Query(default=None, min_length=1, max_length=255),
|
q: str | None = Query(default=None, min_length=1, max_length=255),
|
||||||
auth: AdminAuthContext = Depends(require_admin_access),
|
auth: AdminAuthContext = Depends(require_admin_access),
|
||||||
command: ListUsersCommand = Depends(get_list_users_command),
|
command: ListUsersCommand = Depends(get_list_users_command),
|
||||||
):
|
):
|
||||||
items, total = await command(limit=limit, offset=offset, search=q)
|
items, total = await command(limit=limit, offset=offset, search=q, cursor=cursor)
|
||||||
return UserListResponse(
|
return UserListResponse(
|
||||||
items=[user_to_response(x) for x in items],
|
items=[user_to_response(x) for x in items],
|
||||||
total=total,
|
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)
|
@users_router.get('/{user_id}', response_model=UserResponse)
|
||||||
@@ -77,6 +85,23 @@ async def list_user_wallets(
|
|||||||
return [wallet_to_response(w) for w in wallets]
|
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)
|
@users_router.get('/{user_id}/wallets/mnemonic', response_model=MnemonicResponse)
|
||||||
async def get_user_mnemonic(
|
async def get_user_mnemonic(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
@@ -104,6 +129,18 @@ async def get_user_secret_keys(
|
|||||||
for k in keys
|
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,
|
||||||
|
auth: AdminAuthContext = Depends(require_admin_role('compliance', 'superadmin')),
|
||||||
|
command: SetPasswordCommand = Depends(get_set_password_command),
|
||||||
|
):
|
||||||
|
await command(email=body.email, password=body.password)
|
||||||
|
return ORJSONResponse(content={'message': 'Password updated successfully'})
|
||||||
|
|
||||||
|
|
||||||
@users_router.patch(path='/{user_id}/set_password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
@users_router.patch(path='/{user_id}/set_password', response_class=ORJSONResponse, status_code=status.HTTP_200_OK)
|
||||||
async def set_password(
|
async def set_password(
|
||||||
user_id: str,
|
user_id: str,
|
||||||
|
|||||||
15
src/presentation/schemas/analytics.py
Normal file
15
src/presentation/schemas/analytics.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
class OrderAnalyticsResponse(BaseModel):
|
||||||
|
orders_count: list[int]
|
||||||
|
summed_service_fees: list[Decimal]
|
||||||
|
total_orders: int
|
||||||
|
total_summed_service_fees: Decimal
|
||||||
|
period: int | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class PurchaseRequestAnalyticsResponse(BaseModel):
|
||||||
|
requests_count: list[int]
|
||||||
|
total_requests: int
|
||||||
|
period: int | None = None
|
||||||
@@ -1,7 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
@@ -50,9 +49,9 @@ class SecretKeyResponse(BaseModel):
|
|||||||
|
|
||||||
class CreatePurchaseRequestBody(BaseModel):
|
class CreatePurchaseRequestBody(BaseModel):
|
||||||
organization_id: str
|
organization_id: str
|
||||||
usdt_amount: Decimal = Field(gt=0)
|
rub_amount: Decimal = Field(gt=0)
|
||||||
status: str = 'submitted'
|
status: str = 'submitted'
|
||||||
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)
|
exchange_rate: Decimal | None = Field(default=None, gt=0)
|
||||||
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
@@ -72,8 +71,8 @@ class UpdatePurchaseRequestStatusBody(BaseModel):
|
|||||||
|
|
||||||
class UpdatePurchaseRequestBody(BaseModel):
|
class UpdatePurchaseRequestBody(BaseModel):
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
usdt_amount: Decimal | None = Field(default=None, gt=0)
|
|
||||||
rub_amount: Decimal | None = Field(default=None, gt=0)
|
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)
|
exchange_rate: Decimal | None = Field(default=None, gt=0)
|
||||||
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
service_fee_percent: Decimal | None = Field(default=None, ge=0)
|
||||||
comment: str | None = None
|
comment: str | None = None
|
||||||
@@ -95,8 +94,8 @@ class PurchaseRequestResponse(BaseModel):
|
|||||||
id: str
|
id: str
|
||||||
organization_id: str
|
organization_id: str
|
||||||
status: str
|
status: str
|
||||||
usdt_amount: str
|
rub_amount: str
|
||||||
rub_amount: str | None
|
usdt_amount: str | None
|
||||||
exchange_rate: str | None
|
exchange_rate: str | None
|
||||||
service_fee_percent: str | None
|
service_fee_percent: str | None
|
||||||
comment: str | None
|
comment: str | None
|
||||||
|
|||||||
@@ -4,12 +4,17 @@ 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 (
|
from src.application.domain.entities.user import (
|
||||||
UserEntity,
|
UserEntity,
|
||||||
)
|
)
|
||||||
|
from src.application.domain.entities.order import (
|
||||||
|
OrderEntity,
|
||||||
|
)
|
||||||
from src.presentation.schemas.user import (
|
from src.presentation.schemas.user import (
|
||||||
UserResponse,
|
UserResponse,
|
||||||
)
|
)
|
||||||
@@ -25,6 +30,9 @@ from src.presentation.schemas.entity import (
|
|||||||
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:
|
||||||
@@ -157,3 +165,16 @@ def user_to_response(entity: UserEntity) -> UserResponse:
|
|||||||
updated_at=entity.updated_at.isoformat() if entity.updated_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,
|
kyc_verified_at=entity.kyc_verified_at.isoformat() if entity.kyc_verified_at else None,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def order_to_response(entity: OrderEntity) -> OrderResponse:
|
||||||
|
return OrderResponse(
|
||||||
|
order_id=entity.id,
|
||||||
|
order_status=entity.status,
|
||||||
|
usdt_amount=str(entity.usdt_amount),
|
||||||
|
usdt_exchange_rate=str(entity.usdt_exchange_rate) if entity.usdt_exchange_rate is not None else None,
|
||||||
|
gas_fee=str(entity.gas_fee) if entity.gas_fee is not None else None,
|
||||||
|
total_price=str(entity.total_price) if entity.total_price is not None else None,
|
||||||
|
service_fee=str(entity.service_fee) if entity.service_fee is not None else None,
|
||||||
|
updated_at=entity.updated_at.isoformat() if entity.updated_at else None,
|
||||||
|
)
|
||||||
|
|||||||
50
src/presentation/schemas/order.py
Normal file
50
src/presentation/schemas/order.py
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
from decimal import Decimal
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from src.application.domain.enums import OrderStatus
|
||||||
|
|
||||||
|
|
||||||
|
class OrderDetailResponse(BaseModel):
|
||||||
|
id: str | None = None
|
||||||
|
created_at: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
user_id: str | None = None
|
||||||
|
usdt_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
gas_fee: str | None = None
|
||||||
|
total_price: str | None = None
|
||||||
|
service_fee: str | None = None
|
||||||
|
status: OrderStatus | None = None
|
||||||
|
client_payment_id: str | None = None
|
||||||
|
itpay_payment_qr_url_desktop: str | None = None
|
||||||
|
itpay_payment_qr_url_android: str | None = None
|
||||||
|
itpay_payment_qr_url_ios: str | None = None
|
||||||
|
itpay_payment_qr_image_desktop: str | None = None
|
||||||
|
itpay_payment_qr_image_android: str | None = None
|
||||||
|
itpay_payment_qr_image_ios: str | None = None
|
||||||
|
itpay_id: str | None = None
|
||||||
|
itpay_qr_id: str | None = None
|
||||||
|
itpay_amount: str | None = None
|
||||||
|
itpay_created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrderResponse(BaseModel):
|
||||||
|
order_id: str
|
||||||
|
order_status: OrderStatus | None = None
|
||||||
|
usdt_amount: str | None = None
|
||||||
|
usdt_exchange_rate: str | None = None
|
||||||
|
gas_fee: str | None = None
|
||||||
|
total_price: str | None = None
|
||||||
|
service_fee: str | None = None
|
||||||
|
updated_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class OrdersResponse(BaseModel):
|
||||||
|
items: list[OrderResponse]
|
||||||
|
total: int
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|
||||||
|
class OrdersByDateResponse(BaseModel):
|
||||||
|
items: list[OrderResponse]
|
||||||
|
total: int
|
||||||
|
summed_service_fees: Decimal
|
||||||
|
next_cursor: str | None = None
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
|
|||||||
@@ -1,9 +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 | None = None
|
||||||
password: str
|
password: str
|
||||||
|
|
||||||
@field_validator('password')
|
@field_validator('password')
|
||||||
|
|||||||
53
src/presentation/schemas/risk.py
Normal file
53
src/presentation/schemas/risk.py
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
user_id: str
|
||||||
|
order_id: str | None = None
|
||||||
|
subject_type: str
|
||||||
|
score: int
|
||||||
|
decision: str
|
||||||
|
reasons: list[str]
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class RiskAssessmentsResponse(BaseModel):
|
||||||
|
items: list[RiskAssessmentResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class RiskSummaryResponse(BaseModel):
|
||||||
|
total: int
|
||||||
|
allow: int
|
||||||
|
manual_review: int
|
||||||
|
reject: int
|
||||||
|
average_score: float
|
||||||
|
high_risk: int
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventResponse(BaseModel):
|
||||||
|
id: str
|
||||||
|
actor_type: str
|
||||||
|
actor_id: str | None = None
|
||||||
|
action: str
|
||||||
|
entity_type: str
|
||||||
|
entity_id: str | None = None
|
||||||
|
severity: str
|
||||||
|
metadata: dict
|
||||||
|
created_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditEventsResponse(BaseModel):
|
||||||
|
items: list[AuditEventResponse]
|
||||||
|
total: int
|
||||||
|
|
||||||
|
|
||||||
|
class FunnelResponse(BaseModel):
|
||||||
|
registrations: int
|
||||||
|
kyc_started: int
|
||||||
|
kyc_completed: int
|
||||||
|
first_payment: int
|
||||||
|
successful_operations: int
|
||||||
@@ -1,8 +1,5 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from decimal import Decimal
|
|
||||||
from typing import Any
|
|
||||||
|
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
|
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
@@ -30,3 +27,4 @@ class UserResponse(BaseModel):
|
|||||||
class UserListResponse(BaseModel):
|
class UserListResponse(BaseModel):
|
||||||
items: list[UserResponse]
|
items: list[UserResponse]
|
||||||
total: int
|
total: int
|
||||||
|
next_cursor: str | None = None
|
||||||
|
|||||||
Reference in New Issue
Block a user