init commit

This commit is contained in:
2026-05-11 12:15:03 +03:00
commit 7dbbd98312
96 changed files with 3750 additions and 0 deletions

View File

@@ -0,0 +1 @@
from src.application.abstractions.i_unit_of_work import IUnitOfWork

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

View 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

View File

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

View File

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

View File

@@ -0,0 +1 @@
from src.application.commands.create_kyc_command import CompleteKycCommand,GetKycSessionCommand,PassKycCommand

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

View 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

View 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

View 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

View 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

View 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

View 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

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

View 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

View 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

View 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

View 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

View 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

View File

@@ -0,0 +1,5 @@
from src.application.domain.entities.kyc import KycEntity
from src.application.domain.entities.user import UserEntity
__all__ = ['KycEntity','UserEntity']

View 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

View 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

View File

@@ -0,0 +1,2 @@
from src.application.domain.enums.log_format import LogFormat
from src.application.domain.enums.log_level import LogLevel

View File

@@ -0,0 +1,6 @@
from enum import Enum
class LogFormat(str,Enum):
JSON = 'json'
TEXT = 'text'

View File

@@ -0,0 +1,10 @@
from enum import IntEnum
class LogLevel(IntEnum):
DEBUG = 10
INFO = 20
WARNING = 30
ERROR = 40
CRITICAL = 50
EXCEPTION = 60

View File

@@ -0,0 +1 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException

View 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}'

View File

@@ -0,0 +1 @@
from src.application.services.kyc_personal_data import ensure_adult,extract_personal_data,parse_birth_date

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