init commit
This commit is contained in:
1
src/application/abstractions/__init__.py
Normal file
1
src/application/abstractions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.abstractions.i_unit_of_work import IUnitOfWork
|
||||
19
src/application/abstractions/i_unit_of_work.py
Normal file
19
src/application/abstractions/i_unit_of_work.py
Normal file
@@ -0,0 +1,19 @@
|
||||
from __future__ import annotations
|
||||
from typing import Protocol, runtime_checkable
|
||||
from src.application.abstractions.repositories import IKycRepository,IUserRepository
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class IUnitOfWork(Protocol):
|
||||
async def __aenter__(self) -> 'IUnitOfWork': ...
|
||||
async def __aexit__(self, exc_type, exc_val, exc_tb) -> None: ...
|
||||
|
||||
async def commit(self) -> None: ...
|
||||
async def rollback(self) -> None: ...
|
||||
|
||||
@property
|
||||
def user_repository(self) -> IUserRepository: ...
|
||||
|
||||
@property
|
||||
def kyc_repository(self) -> IKycRepository: ...
|
||||
|
||||
2
src/application/abstractions/repositories/__init__.py
Normal file
2
src/application/abstractions/repositories/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.abstractions.repositories.i_kyc_repository import IKycRepository
|
||||
from src.application.abstractions.repositories.i_user_repository import IUserRepository
|
||||
@@ -0,0 +1,47 @@
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from src.application.domain.entities import KycEntity
|
||||
|
||||
|
||||
class IKycRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_started_session(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str | None,
|
||||
client_user_token: str | None,
|
||||
link: str | None,
|
||||
qr_code: str | None,
|
||||
expires_at: datetime,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_session_result(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
user_token: str,
|
||||
status: str,
|
||||
done_state: bool | None,
|
||||
set_id: str | None,
|
||||
result_data: Any,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def expire_started_sessions(self,*,user_id: str,now: datetime) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_active_session(self,*,user_id: str,now: datetime) -> KycEntity | None:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,34 @@
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from datetime import date
|
||||
from src.application.domain.entities import UserEntity
|
||||
|
||||
|
||||
class IUserRepository(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_user(self, email: str, password_hash: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_user_by_email(self, email: str) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def exists_by_email(self, email: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def update_kyc_data(
|
||||
self,
|
||||
*,
|
||||
user_id: str,
|
||||
first_name: str,
|
||||
last_name: str,
|
||||
birth_date: date,
|
||||
middle_name: str | None,
|
||||
inn: str | None,
|
||||
) -> UserEntity:
|
||||
raise NotImplementedError
|
||||
1
src/application/commands/__init__.py
Normal file
1
src/application/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.commands.create_kyc_command import CompleteKycCommand,GetKycSessionCommand,PassKycCommand
|
||||
183
src/application/commands/create_kyc_command.py
Normal file
183
src/application/commands/create_kyc_command.py
Normal file
@@ -0,0 +1,183 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timedelta,timezone
|
||||
import orjson
|
||||
from ulid import ULID
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IBeorgService,ICache,ILogger,IQueueMessanger
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
|
||||
|
||||
|
||||
KYC_SESSION_TTL = 3600
|
||||
|
||||
|
||||
class PassKycCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
cache: ICache,
|
||||
beorg_service: IBeorgService,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._beorg_service = beorg_service
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> BeorgKycCreateResponse:
|
||||
result = await self._beorg_service.create_identification(client_user_token=user_id)
|
||||
expires_at = _utc_now() + timedelta(seconds=KYC_SESSION_TTL)
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.create_started_session(
|
||||
user_id=user_id,
|
||||
user_token=result.user_token,
|
||||
client_user_token=result.client_user_token,
|
||||
link=result.link,
|
||||
qr_code=result.qr_code,
|
||||
expires_at=expires_at,
|
||||
error=result.error,
|
||||
)
|
||||
await self._cache.set(f'kyc:session:{user_id}',result.model_dump_json(),ttl=KYC_SESSION_TTL)
|
||||
self._logger.info(f'KYC started for user {user_id}')
|
||||
return result
|
||||
|
||||
|
||||
class CompleteKycCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
logger: ILogger,
|
||||
cache: ICache,
|
||||
beorg_service: IBeorgService,
|
||||
queue_messanger: IQueueMessanger,
|
||||
verified_queue: str,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
self._logger = logger
|
||||
self._cache = cache
|
||||
self._beorg_service = beorg_service
|
||||
self._queue_messanger = queue_messanger
|
||||
self._verified_queue = verified_queue
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> BeorgKycResultResponse:
|
||||
session = await self._get_session(user_id)
|
||||
if not session.user_token:
|
||||
raise ApplicationException(status_code=409,message='KYC session has no user token')
|
||||
|
||||
result = await self._beorg_service.get_result(user_token=session.user_token)
|
||||
if result.done_state is None:
|
||||
raise ApplicationException(status_code=409,message='KYC is not completed yet')
|
||||
if result.done_state is False:
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.update_session_result(
|
||||
user_id=user_id,
|
||||
user_token=session.user_token,
|
||||
status='failed',
|
||||
done_state=result.done_state,
|
||||
set_id=result.set_id,
|
||||
result_data=result.data,
|
||||
error='KYC failed',
|
||||
)
|
||||
raise ApplicationException(status_code=400,message='KYC failed')
|
||||
|
||||
personal_data = extract_personal_data(result.data)
|
||||
birth_date = parse_birth_date(personal_data.birth_date)
|
||||
ensure_adult(birth_date)
|
||||
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
user = await unit_of_work.user_repository.update_kyc_data(
|
||||
user_id=user_id,
|
||||
first_name=personal_data.first_name,
|
||||
last_name=personal_data.last_name,
|
||||
middle_name=personal_data.middle_name,
|
||||
birth_date=birth_date,
|
||||
inn=personal_data.inn,
|
||||
)
|
||||
await unit_of_work.kyc_repository.update_session_result(
|
||||
user_id=user_id,
|
||||
user_token=session.user_token,
|
||||
status='completed',
|
||||
done_state=result.done_state,
|
||||
set_id=result.set_id,
|
||||
result_data=result.data,
|
||||
error=None,
|
||||
)
|
||||
await self._cache.set_user(user_id,user,ttl=KYC_SESSION_TTL)
|
||||
await self._cache.delete(f'kyc:session:{user_id}')
|
||||
await self._queue_messanger.publish_to_queue(
|
||||
self._verified_queue,
|
||||
{
|
||||
'user_id': user_id,
|
||||
'kyc_verified': True,
|
||||
'first_name': user.first_name,
|
||||
'last_name': user.last_name,
|
||||
'middle_name': user.middle_name,
|
||||
'birth_date': str(user.birth_date) if user.birth_date else None,
|
||||
'inn': user.inn,
|
||||
'kyc_verified_at': user.kyc_verified_at.isoformat() if user.kyc_verified_at else None,
|
||||
},
|
||||
message_id=str(ULID()),
|
||||
correlation_id=user_id,
|
||||
)
|
||||
self._logger.info(f'KYC completed for user {user_id}')
|
||||
return result
|
||||
|
||||
|
||||
async def _get_session(self,user_id: str) -> BeorgKycCreateResponse:
|
||||
raw = await self._cache.get(f'kyc:session:{user_id}')
|
||||
if raw is not None:
|
||||
return BeorgKycCreateResponse.model_validate(orjson.loads(raw))
|
||||
|
||||
now = _utc_now()
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
||||
session = await unit_of_work.kyc_repository.get_active_session(user_id=user_id,now=now)
|
||||
if session is not None:
|
||||
return BeorgKycCreateResponse(
|
||||
status=True,
|
||||
link=session.link,
|
||||
user_token=session.user_token,
|
||||
client_user_token=session.client_user_token,
|
||||
qr_code=session.qr_code,
|
||||
)
|
||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
||||
|
||||
|
||||
class GetKycSessionCommand:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
unit_of_work: IUnitOfWork,
|
||||
) -> None:
|
||||
self._unit_of_work = unit_of_work
|
||||
|
||||
|
||||
async def __call__(self,user_id: str) -> KycSessionResponse:
|
||||
now = _utc_now()
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.expire_started_sessions(user_id=user_id,now=now)
|
||||
session = await unit_of_work.kyc_repository.get_active_session(user_id=user_id,now=now)
|
||||
if session is None or session.expires_at is None:
|
||||
raise ApplicationException(status_code=404,message='KYC session expired')
|
||||
|
||||
expires_in = max(int((session.expires_at - now).total_seconds()),0)
|
||||
return KycSessionResponse(
|
||||
status=session.status or 'started',
|
||||
link=session.link,
|
||||
qr_code=session.qr_code,
|
||||
user_token=session.user_token,
|
||||
expires_at=session.expires_at,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
|
||||
def _utc_now() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
7
src/application/contracts/__init__.py
Normal file
7
src/application/contracts/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from src.application.contracts.i_logger import ILogger
|
||||
from src.application.contracts.i_jwt_service import IJwtService
|
||||
from src.application.contracts.i_csrf_service import ICsrfService
|
||||
from src.application.contracts.i_cache import ICache
|
||||
from src.application.contracts.i_hash_service import IHashService
|
||||
from src.application.contracts.i_queue_messanger import IQueueMessanger
|
||||
from src.application.contracts.i_beorg_service import IBeorgService
|
||||
14
src/application/contracts/i_beorg_service.py
Normal file
14
src/application/contracts/i_beorg_service.py
Normal file
@@ -0,0 +1,14 @@
|
||||
from abc import ABC,abstractmethod
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse
|
||||
|
||||
|
||||
class IBeorgService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def create_identification(self,client_user_token: str) -> BeorgKycCreateResponse:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
async def get_result(self,user_token: str) -> BeorgKycResultResponse:
|
||||
raise NotImplementedError
|
||||
34
src/application/contracts/i_cache.py
Normal file
34
src/application/contracts/i_cache.py
Normal file
@@ -0,0 +1,34 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
class ICache(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def set(self, key: str, value: str, ttl: int) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_nx(self, key: str, value: str, ttl: int) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get(self, key: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def hget(self, key: str, field: str) -> str | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def delete(self, key: str) -> bool:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def get_user(self, user_id: str) -> dict | None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def set_user(self, user_id: str, user: UserEntity, ttl: int = 300) -> None:
|
||||
raise NotImplementedError
|
||||
23
src/application/contracts/i_csrf_service.py
Normal file
23
src/application/contracts/i_csrf_service.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Optional, Mapping
|
||||
|
||||
|
||||
class ICsrfService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def verify(self, token: str, expected_subject: Optional[str] = None) -> dict[str, Any]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def extract(self, cookies: Mapping[str, str], headers: Mapping[str, str]) -> tuple[Optional[str], Optional[str]]:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
def verify_pair(
|
||||
self,
|
||||
cookie_token: Optional[str],
|
||||
header_token: Optional[str],
|
||||
expected_subject: Optional[str] = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
13
src/application/contracts/i_hash_service.py
Normal file
13
src/application/contracts/i_hash_service.py
Normal file
@@ -0,0 +1,13 @@
|
||||
from abc import ABC,abstractmethod
|
||||
|
||||
|
||||
class IHashService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
def hash(self,value: str) -> str:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
@abstractmethod
|
||||
def verify(self,value: str,hashed_value: str) -> bool:
|
||||
raise NotImplementedError
|
||||
10
src/application/contracts/i_jwt_service.py
Normal file
10
src/application/contracts/i_jwt_service.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from __future__ import annotations
|
||||
from abc import ABC, abstractmethod
|
||||
from src.application.domain.dto import AccessTokenPayload
|
||||
|
||||
|
||||
class IJwtService(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def decode_access_token(self, token: str) -> AccessTokenPayload:
|
||||
raise NotImplementedError
|
||||
68
src/application/contracts/i_logger.py
Normal file
68
src/application/contracts/i_logger.py
Normal file
@@ -0,0 +1,68 @@
|
||||
from typing import Protocol, Optional, Callable
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
|
||||
|
||||
class ILogger(Protocol):
|
||||
"""Interface for synchronous logger with ContextVar support for trace_id."""
|
||||
|
||||
log_format: LogFormat
|
||||
min_level: LogLevel
|
||||
id_generator: Optional[Callable[[], str]]
|
||||
instance_id: str
|
||||
|
||||
def set_format(self, log_format: LogFormat) -> None:
|
||||
"""Set log format using LogFormat enum"""
|
||||
...
|
||||
|
||||
def set_min_level(self, level: LogLevel) -> None:
|
||||
"""Set minimum log level"""
|
||||
...
|
||||
|
||||
def new_trace_id(self) -> str:
|
||||
"""Create and set new trace_id in context"""
|
||||
...
|
||||
|
||||
def set_trace_id(self, trace_id: str) -> None:
|
||||
"""Set existing trace_id in context"""
|
||||
...
|
||||
|
||||
def get_trace_id(self) -> str:
|
||||
"""Get current trace_id from context"""
|
||||
...
|
||||
|
||||
def clear_trace_id(self) -> None:
|
||||
"""Clear the trace_id in the context"""
|
||||
...
|
||||
|
||||
def set_instance_id(self, instance_id: str) -> None:
|
||||
"""Set service instance id (ULID recommended)"""
|
||||
...
|
||||
|
||||
def get_instance_id(self) -> str:
|
||||
"""Get current service instance id"""
|
||||
...
|
||||
|
||||
def debug(self, message: str) -> None:
|
||||
"""Log debug message"""
|
||||
...
|
||||
|
||||
def info(self, message: str) -> None:
|
||||
"""Log info message"""
|
||||
...
|
||||
|
||||
def warning(self, message: str) -> None:
|
||||
"""Log warning message"""
|
||||
...
|
||||
|
||||
def error(self, message: str) -> None:
|
||||
"""Log error message"""
|
||||
...
|
||||
|
||||
def critical(self, message: str) -> None:
|
||||
"""Log critical message"""
|
||||
...
|
||||
|
||||
def exception(self, message: str) -> None:
|
||||
"""Log exception with traceback"""
|
||||
...
|
||||
40
src/application/contracts/i_queue_messanger.py
Normal file
40
src/application/contracts/i_queue_messanger.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Mapping, Any
|
||||
|
||||
|
||||
class IQueueMessanger(ABC):
|
||||
|
||||
@abstractmethod
|
||||
async def connect(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def close(self) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def publish_to_queue(
|
||||
self,
|
||||
queue: str,
|
||||
message: Any,
|
||||
*,
|
||||
persist: bool = True,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@abstractmethod
|
||||
async def publish(
|
||||
self,
|
||||
message: Any,
|
||||
*,
|
||||
exchange: str,
|
||||
routing_key: str,
|
||||
persist: bool = True,
|
||||
headers: Mapping[str, Any] | None = None,
|
||||
correlation_id: str | None = None,
|
||||
message_id: str | None = None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
3
src/application/domain/dto/__init__.py
Normal file
3
src/application/domain/dto/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from src.application.domain.dto.token import AccessTokenPayload, AuthContext
|
||||
from src.application.domain.dto.keys import JwtPublicKey, JwtPublicKeySet
|
||||
from src.application.domain.dto.beorg import BeorgKycCreateResponse,BeorgKycResultResponse,KycPersonalData,KycSessionResponse
|
||||
37
src/application/domain/dto/beorg.py
Normal file
37
src/application/domain/dto/beorg.py
Normal file
@@ -0,0 +1,37 @@
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class BeorgKycCreateResponse(BaseModel):
|
||||
status: bool
|
||||
error: str | None = None
|
||||
link: str | None = None
|
||||
user_token: str | None = None
|
||||
client_user_token: str | None = None
|
||||
qr_code: str | None = None
|
||||
|
||||
|
||||
class BeorgKycResultResponse(BaseModel):
|
||||
done_state: bool | None = None
|
||||
user_token: str
|
||||
client_user_token: str | None = None
|
||||
set_id: str | None = None
|
||||
data: Any = None
|
||||
|
||||
|
||||
class KycPersonalData(BaseModel):
|
||||
first_name: str
|
||||
last_name: str
|
||||
birth_date: str
|
||||
middle_name: str | None = None
|
||||
inn: str | None = None
|
||||
|
||||
|
||||
class KycSessionResponse(BaseModel):
|
||||
status: str
|
||||
link: str | None = None
|
||||
qr_code: str | None = None
|
||||
user_token: str | None = None
|
||||
expires_at: datetime
|
||||
expires_in: int
|
||||
20
src/application/domain/dto/keys.py
Normal file
20
src/application/domain/dto/keys.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional, Dict
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKey:
|
||||
kid: str
|
||||
public_key_pem: str
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JwtPublicKeySet:
|
||||
active: JwtPublicKey
|
||||
previous: Optional[JwtPublicKey] = None
|
||||
|
||||
def public_keys_by_kid(self) -> Dict[str, str]:
|
||||
out = {self.active.kid: self.active.public_key_pem}
|
||||
if self.previous:
|
||||
out[self.previous.kid] = self.previous.public_key_pem
|
||||
return out
|
||||
18
src/application/domain/dto/token.py
Normal file
18
src/application/domain/dto/token.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class AccessTokenPayload(BaseModel):
|
||||
sub: str
|
||||
type: str
|
||||
sid: str
|
||||
iat: int
|
||||
nbf: int
|
||||
exp: int
|
||||
iss: str | None = None
|
||||
aud: str | None = None
|
||||
|
||||
|
||||
class AuthContext(BaseModel):
|
||||
user_id: str
|
||||
sid: str
|
||||
token: AccessTokenPayload
|
||||
5
src/application/domain/entities/__init__.py
Normal file
5
src/application/domain/entities/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from src.application.domain.entities.kyc import KycEntity
|
||||
from src.application.domain.entities.user import UserEntity
|
||||
|
||||
|
||||
__all__ = ['KycEntity','UserEntity']
|
||||
23
src/application/domain/entities/kyc.py
Normal file
23
src/application/domain/entities/kyc.py
Normal file
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class KycEntity:
|
||||
id: str | None = None
|
||||
user_id: str | None = None
|
||||
user_token: str | None = None
|
||||
client_user_token: str | None = None
|
||||
link: str | None = None
|
||||
qr_code: str | None = None
|
||||
status: str | None = None
|
||||
done_state: bool | None = None
|
||||
set_id: str | None = None
|
||||
error: str | None = None
|
||||
result_data: Any = None
|
||||
expires_at: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
30
src/application/domain/entities/user.py
Normal file
30
src/application/domain/entities/user.py
Normal file
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class UserEntity:
|
||||
id: str | None = None
|
||||
email: str | None = None
|
||||
password_hash: str | None = None
|
||||
|
||||
first_name: str | None = None
|
||||
middle_name: str | None = None
|
||||
last_name: str | None = None
|
||||
birth_date: date | None = None
|
||||
|
||||
crypto_wallet: str | None = None
|
||||
phone: str | None = None
|
||||
|
||||
bik: str | None = None
|
||||
account_number: str | None = None
|
||||
card_number: str | None = None
|
||||
inn: str | None = None
|
||||
|
||||
kyc_verified: bool | None = None
|
||||
is_deleted: bool | None = None
|
||||
|
||||
created_at: datetime | None = None
|
||||
updated_at: datetime | None = None
|
||||
kyc_verified_at: datetime | None = None
|
||||
2
src/application/domain/enums/__init__.py
Normal file
2
src/application/domain/enums/__init__.py
Normal file
@@ -0,0 +1,2 @@
|
||||
from src.application.domain.enums.log_format import LogFormat
|
||||
from src.application.domain.enums.log_level import LogLevel
|
||||
6
src/application/domain/enums/log_format.py
Normal file
6
src/application/domain/enums/log_format.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class LogFormat(str,Enum):
|
||||
JSON = 'json'
|
||||
TEXT = 'text'
|
||||
10
src/application/domain/enums/log_level.py
Normal file
10
src/application/domain/enums/log_level.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from enum import IntEnum
|
||||
|
||||
|
||||
class LogLevel(IntEnum):
|
||||
DEBUG = 10
|
||||
INFO = 20
|
||||
WARNING = 30
|
||||
ERROR = 40
|
||||
CRITICAL = 50
|
||||
EXCEPTION = 60
|
||||
1
src/application/domain/exceptions/__init__.py
Normal file
1
src/application/domain/exceptions/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.domain.exceptions.application_exceptions import ApplicationException
|
||||
18
src/application/domain/exceptions/application_exceptions.py
Normal file
18
src/application/domain/exceptions/application_exceptions.py
Normal file
@@ -0,0 +1,18 @@
|
||||
from __future__ import annotations
|
||||
from typing import Mapping
|
||||
|
||||
|
||||
class ApplicationException(Exception):
|
||||
def __init__(
|
||||
self,
|
||||
status_code: int,
|
||||
message: str,
|
||||
headers: Mapping[str, str] | None = None,
|
||||
):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
self.message = message
|
||||
self.headers = headers
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.status_code}: {self.message}'
|
||||
1
src/application/services/__init__.py
Normal file
1
src/application/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from src.application.services.kyc_personal_data import ensure_adult,extract_personal_data,parse_birth_date
|
||||
78
src/application/services/kyc_personal_data.py
Normal file
78
src/application/services/kyc_personal_data.py
Normal file
@@ -0,0 +1,78 @@
|
||||
from __future__ import annotations
|
||||
from datetime import date,datetime
|
||||
from typing import Any
|
||||
from src.application.domain.dto import KycPersonalData
|
||||
from src.application.domain.exceptions import ApplicationException
|
||||
|
||||
|
||||
FIELD_ALIASES = {
|
||||
'first_name': {'first_name','name','given_name','имя'},
|
||||
'last_name': {'last_name','surname','family_name','фамилия'},
|
||||
'middle_name': {'middle_name','patronymic','отчество'},
|
||||
'birth_date': {'birth_date','birthdate','date_birth','birthday','дата рождения'},
|
||||
'inn': {'inn','tax_id','инн'},
|
||||
}
|
||||
|
||||
|
||||
def extract_personal_data(data: Any) -> KycPersonalData:
|
||||
values: dict[str,str] = {}
|
||||
for key,value in _walk(data):
|
||||
normalized = _normalize_key(key)
|
||||
for field,aliases in FIELD_ALIASES.items():
|
||||
if field not in values and normalized in aliases and value not in (None,''):
|
||||
values[field] = str(value).strip()
|
||||
|
||||
missing = [field for field in ('first_name','last_name','birth_date') if not values.get(field)]
|
||||
if missing:
|
||||
raise ApplicationException(status_code=422,message='KYC personal data is incomplete')
|
||||
|
||||
return KycPersonalData(
|
||||
first_name=values['first_name'],
|
||||
last_name=values['last_name'],
|
||||
middle_name=values.get('middle_name'),
|
||||
birth_date=str(_parse_date(values['birth_date'])),
|
||||
inn=values.get('inn'),
|
||||
)
|
||||
|
||||
|
||||
def ensure_adult(birth_date: date) -> None:
|
||||
today = date.today()
|
||||
try:
|
||||
adult_from = date(today.year - 18,today.month,today.day)
|
||||
except ValueError:
|
||||
adult_from = date(today.year - 18,2,28)
|
||||
if birth_date > adult_from:
|
||||
raise ApplicationException(status_code=403,message='KYC is unavailable for users under 18')
|
||||
|
||||
|
||||
def parse_birth_date(value: str) -> date:
|
||||
return _parse_date(value)
|
||||
|
||||
|
||||
def _walk(data: Any) -> list[tuple[str,Any]]:
|
||||
items: list[tuple[str,Any]] = []
|
||||
if isinstance(data,dict):
|
||||
for key,value in data.items():
|
||||
if isinstance(value,dict | list):
|
||||
items.extend(_walk(value))
|
||||
else:
|
||||
items.append((str(key),value))
|
||||
elif isinstance(data,list):
|
||||
for item in data:
|
||||
items.extend(_walk(item))
|
||||
return items
|
||||
|
||||
|
||||
def _normalize_key(key: str) -> str:
|
||||
return key.strip().lower().replace('-','_').replace(' ','_')
|
||||
|
||||
|
||||
def _parse_date(value: str) -> date:
|
||||
clean = value.strip()
|
||||
formats = ('%Y-%m-%d','%d.%m.%Y','%d-%m-%Y','%d/%m/%Y','%Y.%m.%d')
|
||||
for date_format in formats:
|
||||
try:
|
||||
return datetime.strptime(clean,date_format).date()
|
||||
except ValueError:
|
||||
continue
|
||||
raise ApplicationException(status_code=422,message='KYC birth date has invalid format')
|
||||
Reference in New Issue
Block a user