featL add mre

This commit is contained in:
2026-05-12 20:36:59 +03:00
parent dddf0f401f
commit 1b4b7330c8
11 changed files with 113 additions and 23 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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