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, client_user_token: str | None,
link: str | None, link: str | None,
qr_code: str | None, qr_code: str | None,
expires_at: datetime, expires_at: datetime | None,
error: str | None, error: str | None,
) -> None: ) -> None:
raise NotImplementedError raise NotImplementedError

View File

@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime,timedelta,timezone from datetime import datetime,timezone
from src.application.abstractions import IUnitOfWork from src.application.abstractions import IUnitOfWork
from src.application.contracts import IBeorgService,ILogger from src.application.contracts import IBeorgService,ILogger
from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse 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 from src.application.services import ensure_adult,extract_personal_data,parse_birth_date
KYC_SESSION_TTL = 3600
class PassKycCommand: class PassKycCommand:
def __init__( def __init__(
@@ -32,8 +30,7 @@ class PassKycCommand:
f'KYC Beorg identification request finished for user {user_id} ' 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}' 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 without expiry persisted for user {user_id}')
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 database save started 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: async with self._unit_of_work as unit_of_work:
await unit_of_work.kyc_repository.create_started_session( await unit_of_work.kyc_repository.create_started_session(
@@ -42,7 +39,7 @@ class PassKycCommand:
client_user_token=result.client_user_token, client_user_token=result.client_user_token,
link=result.link, link=result.link,
qr_code=result.qr_code, qr_code=result.qr_code,
expires_at=expires_at, expires_at=None,
error=result.error, error=result.error,
) )
self._logger.info(f'KYC session database save finished for user {user_id}') 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: 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) 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) 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() 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( return KycSessionResponse(
status=session.status or 'started', status=session.status or 'started',
link=session.link, link=session.link,
@@ -154,7 +152,7 @@ class GetKycSessionCommand:
user_token=session.user_token, user_token=session.user_token,
done_state=session.done_state, done_state=session.done_state,
error=session.error, error=session.error,
expires_at=session.expires_at, expires_at=expires_at,
expires_in=expires_in, expires_in=expires_in,
) )

View File

@@ -36,5 +36,5 @@ class KycSessionResponse(BaseModel):
user_token: str | None = None user_token: str | None = None
done_state: bool | None = None done_state: bool | None = None
error: str | None = None error: str | None = None
expires_at: datetime expires_at: datetime | None = None
expires_in: int expires_in: int | None = None

View File

@@ -19,6 +19,7 @@ class UserEntity:
passport_data: str | None = None passport_data: str | None = None
inn: str | None = None inn: str | None = None
erc20: str | None = None
kyc_verified: bool | None = None kyc_verified: bool | None = None
is_deleted: bool | None = None is_deleted: bool | None = None

View File

@@ -17,6 +17,9 @@ FIELD_ALIASES = {
def extract_personal_data(data: Any) -> KycPersonalData: def extract_personal_data(data: Any) -> KycPersonalData:
beorg = _try_extract_beorg_documents(data)
if beorg is not None:
return beorg
values: dict[str,str] = {} values: dict[str,str] = {}
for key,value in _walk(data): for key,value in _walk(data):
normalized = _normalize_key(key) 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() today = date.today()
try: try:
adult_from = date(today.year - 18,today.month,today.day) 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._machine_uid = machine_uid
self._token = token self._token = token
self._process_info = process_info self._process_info = process_info
self._expires = 3600
self._timeout = timeout self._timeout = timeout
@@ -36,7 +35,6 @@ class BeorgService(IBeorgService):
'token': self._token, 'token': self._token,
'process_info': self._process_info, 'process_info': self._process_info,
'client_user_token': client_user_token, 'client_user_token': client_user_token,
'expires': self._expires,
} }
timeout = aiohttp.ClientTimeout(total=self._timeout) 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) set_id: Mapped[str | None] = mapped_column(String(255),nullable=True,index=True)
error: Mapped[str | None] = mapped_column(Text,nullable=True) error: Mapped[str | None] = mapped_column(Text,nullable=True)
result_data: Mapped[Any | None] = mapped_column(JSON,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) 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) passport_data: Mapped[str | None] = mapped_column(String(255), nullable=True)
inn: Mapped[str | None] = mapped_column(String(12), 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: 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) kyc_verified_at: Mapped[DateTime | None] = mapped_column(DateTime(timezone=True), nullable=True)

View File

@@ -1,7 +1,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime,timezone from datetime import datetime,timezone
from typing import Any from typing import Any
from sqlalchemy import select from sqlalchemy import or_,select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories import IKycRepository from src.application.abstractions.repositories import IKycRepository
from src.application.domain.entities import KycEntity from src.application.domain.entities import KycEntity
@@ -22,7 +22,7 @@ class KycRepository(IKycRepository):
client_user_token: str | None, client_user_token: str | None,
link: str | None, link: str | None,
qr_code: str | None, qr_code: str | None,
expires_at: datetime, expires_at: datetime | None,
error: str | None, error: str | None,
) -> None: ) -> None:
kyc = KycModel( kyc = KycModel(
@@ -75,6 +75,7 @@ class KycRepository(IKycRepository):
.where( .where(
KycModel.user_id == user_id, KycModel.user_id == user_id,
KycModel.status == 'started', KycModel.status == 'started',
KycModel.expires_at.is_not(None),
KycModel.expires_at <= now, KycModel.expires_at <= now,
) )
) )
@@ -88,6 +89,7 @@ class KycRepository(IKycRepository):
select(KycModel) select(KycModel)
.where( .where(
KycModel.status == 'started', KycModel.status == 'started',
KycModel.expires_at.is_not(None),
KycModel.expires_at <= now, KycModel.expires_at <= now,
) )
) )
@@ -101,8 +103,8 @@ class KycRepository(IKycRepository):
select(KycModel) select(KycModel)
.where( .where(
KycModel.status == 'started', KycModel.status == 'started',
KycModel.expires_at > now,
KycModel.user_token.is_not(None), KycModel.user_token.is_not(None),
or_(KycModel.expires_at.is_(None),KycModel.expires_at > now),
) )
.order_by(KycModel.created_at.asc()) .order_by(KycModel.created_at.asc())
.limit(limit) .limit(limit)
@@ -116,7 +118,7 @@ class KycRepository(IKycRepository):
.where( .where(
KycModel.user_id == user_id, KycModel.user_id == user_id,
KycModel.status == 'started', KycModel.status == 'started',
KycModel.expires_at > now, or_(KycModel.expires_at.is_(None),KycModel.expires_at > now),
) )
.order_by(KycModel.created_at.desc()) .order_by(KycModel.created_at.desc())
.limit(1) .limit(1)

View File

@@ -74,6 +74,7 @@ class UserRepository(IUserRepository):
phone=user.phone, phone=user.phone,
passport_data=user.passport_data, passport_data=user.passport_data,
inn=user.inn, inn=user.inn,
erc20=user.erc20,
kyc_verified=user.kyc_verified, kyc_verified=user.kyc_verified,
is_deleted=user.is_deleted, is_deleted=user.is_deleted,
created_at=user.created_at, created_at=user.created_at,

View File

@@ -56,7 +56,7 @@ GET_KYC_SESSION_RESPONSES = {
response_model=BeorgKycCreateResponse, response_model=BeorgKycCreateResponse,
responses=CREATE_KYC_RESPONSES, responses=CREATE_KYC_RESPONSES,
summary='Start KYC session', 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( async def create_kyc(
auth: AuthContext = Depends(require_access_token), auth: AuthContext = Depends(require_access_token),
@@ -71,7 +71,7 @@ async def create_kyc(
response_model=KycSessionResponse, response_model=KycSessionResponse,
responses=GET_KYC_SESSION_RESPONSES, responses=GET_KYC_SESSION_RESPONSES,
summary='Get KYC session', 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( async def get_kyc_session(
auth: AuthContext = Depends(require_access_token), auth: AuthContext = Depends(require_access_token),