diff --git a/src/application/abstractions/repositories/i_kyc_repository.py b/src/application/abstractions/repositories/i_kyc_repository.py index 7e70ffc..8e7059e 100644 --- a/src/application/abstractions/repositories/i_kyc_repository.py +++ b/src/application/abstractions/repositories/i_kyc_repository.py @@ -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 diff --git a/src/application/commands/create_kyc_command.py b/src/application/commands/create_kyc_command.py index cdcf442..c769683 100644 --- a/src/application/commands/create_kyc_command.py +++ b/src/application/commands/create_kyc_command.py @@ -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, ) diff --git a/src/application/domain/dto/beorg.py b/src/application/domain/dto/beorg.py index f05a5e3..8c82803 100644 --- a/src/application/domain/dto/beorg.py +++ b/src/application/domain/dto/beorg.py @@ -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 diff --git a/src/application/domain/entities/user.py b/src/application/domain/entities/user.py index 5857676..b7c2634 100644 --- a/src/application/domain/entities/user.py +++ b/src/application/domain/entities/user.py @@ -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 diff --git a/src/application/services/kyc_personal_data.py b/src/application/services/kyc_personal_data.py index cb88670..6c86e25 100644 --- a/src/application/services/kyc_personal_data.py +++ b/src/application/services/kyc_personal_data.py @@ -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) diff --git a/src/infrastructure/beorg/client.py b/src/infrastructure/beorg/client.py index 097c894..0fdb84e 100644 --- a/src/infrastructure/beorg/client.py +++ b/src/infrastructure/beorg/client.py @@ -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) diff --git a/src/infrastructure/database/models/kyc.py b/src/infrastructure/database/models/kyc.py index 85a2d16..f5dceb6 100644 --- a/src/infrastructure/database/models/kyc.py +++ b/src/infrastructure/database/models/kyc.py @@ -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) diff --git a/src/infrastructure/database/models/user.py b/src/infrastructure/database/models/user.py index 2f4444f..ecdb681 100644 --- a/src/infrastructure/database/models/user.py +++ b/src/infrastructure/database/models/user.py @@ -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) diff --git a/src/infrastructure/database/repositories/kyc_repository.py b/src/infrastructure/database/repositories/kyc_repository.py index 4e30706..a672adb 100644 --- a/src/infrastructure/database/repositories/kyc_repository.py +++ b/src/infrastructure/database/repositories/kyc_repository.py @@ -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) diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index 2e0960c..9754096 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -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, diff --git a/src/presentation/routing/kyc.py b/src/presentation/routing/kyc.py index badcac0..3d2d3b5 100644 --- a/src/presentation/routing/kyc.py +++ b/src/presentation/routing/kyc.py @@ -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),