featL add mre
This commit is contained in:
@@ -16,7 +16,7 @@ class IKycRepository(ABC):
|
||||
client_user_token: str | None,
|
||||
link: str | None,
|
||||
qr_code: str | None,
|
||||
expires_at: datetime,
|
||||
expires_at: datetime | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
raise NotImplementedError
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timedelta,timezone
|
||||
from datetime import datetime,timezone
|
||||
|
||||
from src.application.abstractions import IUnitOfWork
|
||||
from src.application.contracts import IBeorgService,ILogger
|
||||
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse
|
||||
@@ -7,9 +8,6 @@ from src.application.domain.exceptions import ApplicationException,KycFailedExce
|
||||
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
|
||||
|
||||
|
||||
KYC_SESSION_TTL = 3600
|
||||
|
||||
|
||||
class PassKycCommand:
|
||||
|
||||
def __init__(
|
||||
@@ -32,8 +30,7 @@ class PassKycCommand:
|
||||
f'KYC Beorg identification request finished for user {user_id} '
|
||||
f'status={result.status} user_token={result.user_token} client_user_token={result.client_user_token}'
|
||||
)
|
||||
expires_at = _utc_now() + timedelta(seconds=KYC_SESSION_TTL)
|
||||
self._logger.info(f'KYC session expiration calculated for user {user_id} ttl={KYC_SESSION_TTL} expires_at={expires_at.isoformat()}')
|
||||
self._logger.info(f'KYC session without expiry persisted for user {user_id}')
|
||||
self._logger.info(f'KYC session database save started for user {user_id}')
|
||||
async with self._unit_of_work as unit_of_work:
|
||||
await unit_of_work.kyc_repository.create_started_session(
|
||||
@@ -42,7 +39,7 @@ class PassKycCommand:
|
||||
client_user_token=result.client_user_token,
|
||||
link=result.link,
|
||||
qr_code=result.qr_code,
|
||||
expires_at=expires_at,
|
||||
expires_at=None,
|
||||
error=result.error,
|
||||
)
|
||||
self._logger.info(f'KYC session database save finished for user {user_id}')
|
||||
@@ -143,10 +140,11 @@ class GetKycSessionCommand:
|
||||
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_latest_session(user_id=user_id)
|
||||
if session is None or session.expires_at is None:
|
||||
if session is None:
|
||||
raise KycSessionExpiredException()
|
||||
|
||||
expires_in = max(int((session.expires_at - now).total_seconds()),0)
|
||||
expires_at = session.expires_at
|
||||
expires_in = None if expires_at is None else max(int((expires_at - now).total_seconds()),0)
|
||||
return KycSessionResponse(
|
||||
status=session.status or 'started',
|
||||
link=session.link,
|
||||
@@ -154,7 +152,7 @@ class GetKycSessionCommand:
|
||||
user_token=session.user_token,
|
||||
done_state=session.done_state,
|
||||
error=session.error,
|
||||
expires_at=session.expires_at,
|
||||
expires_at=expires_at,
|
||||
expires_in=expires_in,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,5 +36,5 @@ class KycSessionResponse(BaseModel):
|
||||
user_token: str | None = None
|
||||
done_state: bool | None = None
|
||||
error: str | None = None
|
||||
expires_at: datetime
|
||||
expires_in: int
|
||||
expires_at: datetime | None = None
|
||||
expires_in: int | None = None
|
||||
|
||||
@@ -19,6 +19,7 @@ class UserEntity:
|
||||
|
||||
passport_data: str | None = None
|
||||
inn: str | None = None
|
||||
erc20: str | None = None
|
||||
|
||||
kyc_verified: bool | None = None
|
||||
is_deleted: bool | None = None
|
||||
|
||||
@@ -17,6 +17,9 @@ FIELD_ALIASES = {
|
||||
|
||||
|
||||
def extract_personal_data(data: Any) -> KycPersonalData:
|
||||
beorg = _try_extract_beorg_documents(data)
|
||||
if beorg is not None:
|
||||
return beorg
|
||||
values: dict[str,str] = {}
|
||||
for key,value in _walk(data):
|
||||
normalized = _normalize_key(key)
|
||||
@@ -46,7 +49,93 @@ def extract_personal_data(data: Any) -> KycPersonalData:
|
||||
)
|
||||
|
||||
|
||||
def ensure_adult(birth_date: date) -> None:
|
||||
def _try_extract_beorg_documents(data: Any) -> KycPersonalData | None:
|
||||
if isinstance(data,dict) and 'documents' not in data:
|
||||
inner = data.get('data')
|
||||
if isinstance(inner,dict):
|
||||
nested = _try_extract_beorg_documents(inner)
|
||||
if nested is not None:
|
||||
return nested
|
||||
if not isinstance(data,dict):
|
||||
return None
|
||||
documents = data.get('documents')
|
||||
if not isinstance(documents,list) or not documents:
|
||||
return None
|
||||
|
||||
passport: dict[str,Any] | None = None
|
||||
for item in documents:
|
||||
if not isinstance(item,dict):
|
||||
continue
|
||||
dtype = str(item.get('type') or '').strip().upper()
|
||||
dkey = str(item.get('key') or '').strip().upper()
|
||||
if dtype == 'PASSPORT' or 'PASSPORT' in dtype or dkey.startswith('PASSPORT'):
|
||||
passport = item
|
||||
break
|
||||
|
||||
if passport is None:
|
||||
for item in documents:
|
||||
if not isinstance(item,dict):
|
||||
continue
|
||||
pdata = item.get('data')
|
||||
if isinstance(pdata,dict) and any(k in pdata for k in ('LastName','FirstName','Series','Number')):
|
||||
passport = item
|
||||
break
|
||||
|
||||
if passport is None or not isinstance(passport.get('data'),dict):
|
||||
return None
|
||||
|
||||
pdata = passport['data']
|
||||
last_name = _norm_text(pdata.get('LastName'),pdata.get('last_name'))
|
||||
first_name = _norm_text(pdata.get('FirstName'),pdata.get('first_name'))
|
||||
middle_raw = pdata.get('MiddleName')
|
||||
if middle_raw is None:
|
||||
middle_raw = pdata.get('middle_name')
|
||||
ms = _norm_scalar(middle_raw)
|
||||
middle_name = ms if ms else None
|
||||
birth_raw = pdata.get('BirthDate') or pdata.get('birth_date')
|
||||
birth_ts = _norm_scalar(birth_raw)
|
||||
birth_date_raw = birth_ts if birth_ts else ''
|
||||
series = (_norm_scalar(pdata.get('Series')) or '').strip()
|
||||
number = (_norm_scalar(pdata.get('Number')) or '').strip()
|
||||
passport_data = f'{series}{number}'.strip() or None
|
||||
|
||||
inn_val = None
|
||||
meta = passport.get('metadata')
|
||||
if isinstance(meta,dict):
|
||||
ext = meta.get('external_integrations')
|
||||
if isinstance(ext,dict):
|
||||
inn_block = ext.get('inn')
|
||||
if isinstance(inn_block,dict):
|
||||
val = inn_block.get('value')
|
||||
if val not in (None,'',False):
|
||||
inn_val = str(val).strip()
|
||||
|
||||
if not first_name or not last_name or not birth_date_raw:
|
||||
return None
|
||||
|
||||
return KycPersonalData(
|
||||
first_name=first_name,
|
||||
last_name=last_name,
|
||||
middle_name=middle_name,
|
||||
birth_date=str(_parse_date(birth_date_raw)),
|
||||
inn=inn_val,
|
||||
passport_data=passport_data,
|
||||
)
|
||||
|
||||
|
||||
def _norm_scalar(value: Any) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
s = str(value).strip()
|
||||
return s if s else None
|
||||
|
||||
|
||||
def _norm_text(*candidates: Any) -> str:
|
||||
for cand in candidates:
|
||||
s = _norm_scalar(cand)
|
||||
if s:
|
||||
return s
|
||||
return ''
|
||||
today = date.today()
|
||||
try:
|
||||
adult_from = date(today.year - 18,today.month,today.day)
|
||||
|
||||
@@ -24,7 +24,6 @@ class BeorgService(IBeorgService):
|
||||
self._machine_uid = machine_uid
|
||||
self._token = token
|
||||
self._process_info = process_info
|
||||
self._expires = 3600
|
||||
self._timeout = timeout
|
||||
|
||||
|
||||
@@ -36,7 +35,6 @@ class BeorgService(IBeorgService):
|
||||
'token': self._token,
|
||||
'process_info': self._process_info,
|
||||
'client_user_token': client_user_token,
|
||||
'expires': self._expires,
|
||||
}
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=self._timeout)
|
||||
|
||||
@@ -20,5 +20,5 @@ class KycModel(Base,UlidPrimaryKeyMixin,AuditTimestampsMixin,SoftDeleteMixin):
|
||||
set_id: Mapped[str | None] = mapped_column(String(255),nullable=True,index=True)
|
||||
error: Mapped[str | None] = mapped_column(Text,nullable=True)
|
||||
result_data: Mapped[Any | None] = mapped_column(JSON,nullable=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True),nullable=False,index=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True),nullable=True,index=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True),nullable=True)
|
||||
|
||||
@@ -22,6 +22,7 @@ class UserModel(Base, UlidPrimaryKeyMixin, AuditTimestampsMixin, SoftDeleteMixin
|
||||
|
||||
passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
inn: Mapped[str | None] = mapped_column(String(12), nullable=True)
|
||||
erc20: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
|
||||
kyc_verified: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default='false', default=False)
|
||||
kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
from __future__ import annotations
|
||||
from datetime import datetime,timezone
|
||||
from typing import Any
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import or_,select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from src.application.abstractions.repositories import IKycRepository
|
||||
from src.application.domain.entities import KycEntity
|
||||
@@ -22,7 +22,7 @@ class KycRepository(IKycRepository):
|
||||
client_user_token: str | None,
|
||||
link: str | None,
|
||||
qr_code: str | None,
|
||||
expires_at: datetime,
|
||||
expires_at: datetime | None,
|
||||
error: str | None,
|
||||
) -> None:
|
||||
kyc = KycModel(
|
||||
@@ -75,6 +75,7 @@ class KycRepository(IKycRepository):
|
||||
.where(
|
||||
KycModel.user_id == user_id,
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at.is_not(None),
|
||||
KycModel.expires_at <= now,
|
||||
)
|
||||
)
|
||||
@@ -88,6 +89,7 @@ class KycRepository(IKycRepository):
|
||||
select(KycModel)
|
||||
.where(
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at.is_not(None),
|
||||
KycModel.expires_at <= now,
|
||||
)
|
||||
)
|
||||
@@ -101,8 +103,8 @@ class KycRepository(IKycRepository):
|
||||
select(KycModel)
|
||||
.where(
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at > now,
|
||||
KycModel.user_token.is_not(None),
|
||||
or_(KycModel.expires_at.is_(None),KycModel.expires_at > now),
|
||||
)
|
||||
.order_by(KycModel.created_at.asc())
|
||||
.limit(limit)
|
||||
@@ -116,7 +118,7 @@ class KycRepository(IKycRepository):
|
||||
.where(
|
||||
KycModel.user_id == user_id,
|
||||
KycModel.status == 'started',
|
||||
KycModel.expires_at > now,
|
||||
or_(KycModel.expires_at.is_(None),KycModel.expires_at > now),
|
||||
)
|
||||
.order_by(KycModel.created_at.desc())
|
||||
.limit(1)
|
||||
|
||||
@@ -74,6 +74,7 @@ class UserRepository(IUserRepository):
|
||||
phone=user.phone,
|
||||
passport_data=user.passport_data,
|
||||
inn=user.inn,
|
||||
erc20=user.erc20,
|
||||
kyc_verified=user.kyc_verified,
|
||||
is_deleted=user.is_deleted,
|
||||
created_at=user.created_at,
|
||||
|
||||
@@ -56,7 +56,7 @@ GET_KYC_SESSION_RESPONSES = {
|
||||
response_model=BeorgKycCreateResponse,
|
||||
responses=CREATE_KYC_RESPONSES,
|
||||
summary='Start KYC session',
|
||||
description='Creates a Beorg KYC session for one hour and returns link, user token and QR code.',
|
||||
description='Creates a Beorg KYC session without link expiry at the issuer and persists it without local expiration.',
|
||||
)
|
||||
async def create_kyc(
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
@@ -71,7 +71,7 @@ async def create_kyc(
|
||||
response_model=KycSessionResponse,
|
||||
responses=GET_KYC_SESSION_RESPONSES,
|
||||
summary='Get KYC session',
|
||||
description='Returns latest KYC session status, link, QR code and expiration data.',
|
||||
description='Returns latest KYC session; expires_at and expires_in are omitted when the session has no expiry.',
|
||||
)
|
||||
async def get_kyc_session(
|
||||
auth: AuthContext = Depends(require_access_token),
|
||||
|
||||
Reference in New Issue
Block a user