From 31d097c3c256c635577477a5111cfd6b9479e0c9 Mon Sep 17 00:00:00 2001 From: Noloquideus Date: Fri, 22 May 2026 01:33:17 +0300 Subject: [PATCH] feat: delete double verify in one passport --- migrations/pg_004_users_avatar_link.sql | 1 - .../repositories/i_user_repository.py | 5 + .../commands/create_kyc_command.py | 111 +++++----- src/application/domain/exceptions/__init__.py | 2 +- .../domain/exceptions/kyc_exceptions.py | 10 + src/application/services/__init__.py | 2 +- src/application/services/kyc_personal_data.py | 199 ++++++++++++++---- .../database/repositories/user_repository.py | 43 +++- 8 files changed, 278 insertions(+), 95 deletions(-) delete mode 100644 migrations/pg_004_users_avatar_link.sql diff --git a/migrations/pg_004_users_avatar_link.sql b/migrations/pg_004_users_avatar_link.sql deleted file mode 100644 index aaab346..0000000 --- a/migrations/pg_004_users_avatar_link.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE users ADD COLUMN avatar_link TEXT; diff --git a/src/application/abstractions/repositories/i_user_repository.py b/src/application/abstractions/repositories/i_user_repository.py index 22866d1..47cfee7 100644 --- a/src/application/abstractions/repositories/i_user_repository.py +++ b/src/application/abstractions/repositories/i_user_repository.py @@ -20,6 +20,11 @@ class IUserRepository(ABC): raise NotImplementedError + @abstractmethod + async def exists_verified_by_passport(self,*,passport_data: str,exclude_user_id: str) -> bool: + raise NotImplementedError + + @abstractmethod async def update_kyc_data( self, diff --git a/src/application/commands/create_kyc_command.py b/src/application/commands/create_kyc_command.py index 16814cd..eccf5b3 100644 --- a/src/application/commands/create_kyc_command.py +++ b/src/application/commands/create_kyc_command.py @@ -1,12 +1,13 @@ from __future__ import annotations from datetime import datetime,timezone,timedelta +from typing import Any from src.application.abstractions import IUnitOfWork from src.application.contracts import IBeorgService,ILogger -from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycSessionResponse +from src.application.domain.dto import BeorgKycCreateResponse,BeorgKycResultResponse,KycPersonalData,KycSessionResponse from src.application.domain.entities import KycEntity -from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycSessionExpiredException,KycSessionMissingUserTokenException -from src.application.services import ensure_adult,extract_personal_data,parse_birth_date +from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycPassportAlreadyVerifiedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException +from src.application.services import ensure_adult,extract_personal_data,normalize_passport_data,parse_birth_date def _kyc_started_entity_to_create_response(entity: KycEntity) -> BeorgKycCreateResponse: @@ -105,28 +106,15 @@ class CompleteKycCommand: raise KycFailedException() 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: - 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, - passport_data=personal_data.passport_data, - ) - 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 _complete_kyc_session( + unit_of_work=self._unit_of_work, + user_id=user_id, + user_token=session.user_token, + done_state=result.done_state, + set_id=result.set_id, + result_data=result.data, + personal_data=personal_data, + ) self._logger.info(f'KYC completed for user {user_id}') return result @@ -236,30 +224,59 @@ class PollKycSessionsCommand: return 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: - 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, - passport_data=personal_data.passport_data, - ) - await unit_of_work.kyc_repository.update_session_result( - user_id=user_id, - user_token=user_token, - status='completed', - done_state=result.done_state, - set_id=result.set_id, - result_data=result.data, - error=None, - ) + await _complete_kyc_session( + unit_of_work=self._unit_of_work, + user_id=user_id, + user_token=user_token, + done_state=result.done_state, + set_id=result.set_id, + result_data=result.data, + personal_data=personal_data, + ) self._logger.info(f'KYC completed for user {user_id}') +async def _complete_kyc_session( + *, + unit_of_work: IUnitOfWork, + user_id: str, + user_token: str, + done_state: bool, + set_id: str | None, + result_data: Any, + personal_data: KycPersonalData, +) -> None: + birth_date = parse_birth_date(personal_data.birth_date) + ensure_adult(birth_date) + passport_data = normalize_passport_data(personal_data.passport_data) + if not passport_data: + raise KycPersonalDataIncompleteException() + + async with unit_of_work as uow: + if await uow.user_repository.exists_verified_by_passport( + passport_data=passport_data, + exclude_user_id=user_id, + ): + raise KycPassportAlreadyVerifiedException() + await uow.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, + passport_data=passport_data, + ) + await uow.kyc_repository.update_session_result( + user_id=user_id, + user_token=user_token, + status='completed', + done_state=done_state, + set_id=set_id, + result_data=result_data, + error=None, + ) + + def _utc_now() -> datetime: return datetime.now(timezone.utc) diff --git a/src/application/domain/exceptions/__init__.py b/src/application/domain/exceptions/__init__.py index fc5560b..8f521c4 100644 --- a/src/application/domain/exceptions/__init__.py +++ b/src/application/domain/exceptions/__init__.py @@ -1,2 +1,2 @@ from src.application.domain.exceptions.application_exceptions import ApplicationException -from src.application.domain.exceptions.kyc_exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException,CsrfRequestRequiredException,InvalidTokenException,JwtDecodeFailedException,KycAgeRestrictedException,KycBirthDateInvalidException,KycFailedException,KycNotCompletedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException,NotAuthenticatedException,UnauthorizedException,UserNotFoundException \ No newline at end of file +from src.application.domain.exceptions.kyc_exceptions import BeorgConfigException,BeorgRejectedException,BeorgUnavailableException,CsrfRequestRequiredException,InvalidTokenException,JwtDecodeFailedException,KycAgeRestrictedException,KycBirthDateInvalidException,KycFailedException,KycNotCompletedException,KycPassportAlreadyVerifiedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException,NotAuthenticatedException,UnauthorizedException,UserNotFoundException \ No newline at end of file diff --git a/src/application/domain/exceptions/kyc_exceptions.py b/src/application/domain/exceptions/kyc_exceptions.py index 57a239a..87da82b 100644 --- a/src/application/domain/exceptions/kyc_exceptions.py +++ b/src/application/domain/exceptions/kyc_exceptions.py @@ -80,6 +80,16 @@ class KycAgeRestrictedException(ApplicationException): super().__init__(status_code=403,message='KYC is unavailable for users under 18',error_code='kyc_age_restricted') +class KycPassportAlreadyVerifiedException(ApplicationException): + + def __init__(self) -> None: + super().__init__( + status_code=403, + message='This passport is already linked to a verified account', + error_code='kyc_passport_already_verified', + ) + + class BeorgUnavailableException(ApplicationException): def __init__(self) -> None: diff --git a/src/application/services/__init__.py b/src/application/services/__init__.py index 36ffbab..18da335 100644 --- a/src/application/services/__init__.py +++ b/src/application/services/__init__.py @@ -1 +1 @@ -from src.application.services.kyc_personal_data import ensure_adult,extract_personal_data,parse_birth_date +from src.application.services.kyc_personal_data import ensure_adult,extract_personal_data,normalize_passport_data,parse_birth_date diff --git a/src/application/services/kyc_personal_data.py b/src/application/services/kyc_personal_data.py index e4a25ae..2eedc51 100644 --- a/src/application/services/kyc_personal_data.py +++ b/src/application/services/kyc_personal_data.py @@ -1,5 +1,5 @@ from __future__ import annotations -from datetime import date,datetime +from datetime import date,datetime,timezone from typing import Any from src.application.domain.dto import KycPersonalData from src.application.domain.exceptions import KycAgeRestrictedException,KycBirthDateInvalidException,KycPersonalDataIncompleteException @@ -45,70 +45,96 @@ def extract_personal_data(data: Any) -> KycPersonalData: middle_name=values.get('middle_name'), birth_date=str(_parse_date(values['birth_date'])), inn=values.get('inn'), - passport_data=values.get('passport_data'), + passport_data=normalize_passport_data(values.get('passport_data')), ) 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 + for inner_key in ('data','recognition','Recognition','RecognitionResult','recognition_result','payload'): + inner = data.get(inner_key) + 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 + prioritized: list[dict[str,Any]] = [] + rest: list[dict[str,Any]] = [] 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 + prioritized.append(item) + else: + rest.append(item) - 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 + for item in prioritized + rest: + pdata = item.get('data') + if not isinstance(pdata,dict): + continue + meta = item.get('metadata') + parsed = _personal_from_passport_pdata(pdata,meta if isinstance(meta,dict) else None) + if parsed is not None: + return parsed - if passport is None or not isinstance(passport.get('data'),dict): - return None + 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')) + +def _personal_from_passport_pdata( + pdata: dict[str,Any], + metadata: dict[str,Any] | None, +) -> KycPersonalData | None: + last_name = _norm_text( + pdata.get('LastName'), + pdata.get('last_name'), + pdata.get('Surname'), + pdata.get('surname'), + pdata.get('FamilyName'), + pdata.get('familyName'), + ) + first_name = _norm_text( + pdata.get('FirstName'), + pdata.get('first_name'), + pdata.get('GivenName'), + pdata.get('givenName'), + pdata.get('Name'), + pdata.get('name'), + ) middle_raw = pdata.get('MiddleName') if middle_raw is None: middle_raw = pdata.get('middle_name') + if middle_raw is None: + middle_raw = pdata.get('Patronymic') 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 '' + + birth_date_raw = '' + for cand in ( + pdata.get('BirthDate'), + pdata.get('birth_date'), + pdata.get('birthDate'), + pdata.get('DateOfBirth'), + pdata.get('dateOfBirth'), + pdata.get('BirthDay'), + pdata.get('birthDay'), + ): + bs = _birth_as_string(cand) + if bs: + birth_date_raw = bs + break + series = (_norm_scalar(pdata.get('Series')) or '').strip() number = (_norm_scalar(pdata.get('Number')) or '').strip() - passport_data = f'{series}{number}'.strip() or None + passport_data = normalize_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() + inn_val = _extract_inn_from_metadata(metadata) if not first_name or not last_name or not birth_date_raw: return None @@ -123,21 +149,86 @@ def _try_extract_beorg_documents(data: Any) -> KycPersonalData | None: ) -def _norm_scalar(value: Any) -> str | None: +def _extract_inn_from_metadata(metadata: dict[str,Any] | None) -> str | None: + if metadata is None: + return None + ext = metadata.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): + return str(val).strip() + return None + + +def _field_as_text(value: Any) -> str | None: if value is None: return None - s = str(value).strip() - return s if s else None + if isinstance(value,str): + s = value.strip() + return s if s else None + if isinstance(value,(int,float)): + if isinstance(value,float) and value != value: + return None + return str(value).strip() + if isinstance(value,bool): + return None + if isinstance(value,dict): + for k in ('Text','text','Value','value','recognizedText','RecognizedText','recognizedValue'): + if k in value: + inner = _field_as_text(value[k]) + if inner: + return inner + return None + return None + + +def _birth_as_string(raw: Any) -> str | None: + if raw is None: + return None + text = _field_as_text(raw) + if text: + return text + if isinstance(raw,(int,float)): + ts = float(raw) + if ts > 1e12: + ts /= 1000.0 + try: + return str(datetime.fromtimestamp(ts,tz=timezone.utc).date()) + except (OverflowError,OSError,ValueError): + return None + if isinstance(raw,dict): + y = raw.get('year') or raw.get('Year') + m = raw.get('month') or raw.get('Month') + d = raw.get('day') or raw.get('Day') + if y is not None and m is not None and d is not None: + try: + return f'{int(y):04d}-{int(m):02d}-{int(d):02d}' + except (ValueError,TypeError): + return None + return None + + +def _norm_scalar(value: Any) -> str | None: + return _field_as_text(value) def _norm_text(*candidates: Any) -> str: for cand in candidates: - s = _norm_scalar(cand) + s = _field_as_text(cand) if s: return s return '' +def normalize_passport_data(value: str | None) -> str | None: + if value is None: + return None + digits = ''.join(char for char in value if char.isdigit()) + return digits if digits else None + + def ensure_adult(birth_date: date) -> None: today = date.today() try: @@ -156,7 +247,12 @@ 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): + if isinstance(value,dict): + leaf = _field_as_text(value) + if leaf: + items.append((str(key),leaf)) + items.extend(_walk(value)) + elif isinstance(value,list): items.extend(_walk(value)) else: items.append((str(key),value)) @@ -172,6 +268,25 @@ def _normalize_key(key: str) -> str: def _parse_date(value: str) -> date: clean = value.strip() + head = clean + if head.endswith('Z'): + head = head[:-1] + if 'T' in head: + head = head.split('T',1)[0] + if len(head) >= 10 and head[4] == '-' and head[7] == '-': + try: + return datetime.strptime(head[:10],'%Y-%m-%d').date() + except ValueError: + pass + try: + iso_part = clean.replace('Z','+00:00') + if '+' in iso_part: + iso_part = iso_part.split('+',1)[0] + if 'T' in iso_part: + iso_part = iso_part.split('T',1)[0] + return datetime.fromisoformat(iso_part).date() + except ValueError: + pass formats = ('%Y-%m-%d','%d.%m.%Y','%d-%m-%Y','%d/%m/%Y','%Y.%m.%d') for date_format in formats: try: diff --git a/src/infrastructure/database/repositories/user_repository.py b/src/infrastructure/database/repositories/user_repository.py index e18e1ba..c7c0eac 100644 --- a/src/infrastructure/database/repositories/user_repository.py +++ b/src/infrastructure/database/repositories/user_repository.py @@ -1,13 +1,17 @@ from __future__ import annotations from datetime import date,datetime,timezone -from sqlalchemy import select +from sqlalchemy import func,select +from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession from src.application.abstractions.repositories import IUserRepository from src.application.domain.entities import UserEntity -from src.application.domain.exceptions import UserNotFoundException +from src.application.domain.exceptions import KycPassportAlreadyVerifiedException,UserNotFoundException from src.infrastructure.database.models.user import UserModel +VERIFIED_PASSPORT_UNIQUE_INDEX = 'users_verified_passport_unique_idx' + + class UserRepository(IUserRepository): def __init__(self,session: AsyncSession) -> None: @@ -34,6 +38,20 @@ class UserRepository(IUserRepository): return result.scalar_one_or_none() is not None + async def exists_verified_by_passport(self,*,passport_data: str,exclude_user_id: str) -> bool: + passport_digits = func.regexp_replace(UserModel.passport_data,'[^0-9]','','g') + result = await self._session.execute( + select(UserModel.id).where( + UserModel.kyc_verified.is_(True), + UserModel.is_deleted.is_(False), + UserModel.id != exclude_user_id, + UserModel.passport_data.isnot(None), + passport_digits == passport_data, + ) + ) + return result.scalar_one_or_none() is not None + + async def update_kyc_data( self, *, @@ -57,7 +75,12 @@ class UserRepository(IUserRepository): user.passport_data = passport_data user.kyc_verified = True user.kyc_verified_at = datetime.now(timezone.utc) - await self._session.flush() + try: + await self._session.flush() + except IntegrityError as exc: + if _is_verified_passport_unique_violation(exc): + raise KycPassportAlreadyVerifiedException() from exc + raise return self._to_entity(user) @@ -82,3 +105,17 @@ class UserRepository(IUserRepository): updated_at=user.updated_at, kyc_verified_at=user.kyc_verified_at, ) + + +def _is_verified_passport_unique_violation(exc: IntegrityError) -> bool: + orig = exc.orig + if orig is None: + return False + pgcode = getattr(orig,'sqlstate',None) or getattr(orig,'pgcode',None) + if pgcode != '23505': + return False + constraint = getattr(orig,'constraint_name',None) + if constraint == VERIFIED_PASSPORT_UNIQUE_INDEX: + return True + detail = str(orig).lower() + return VERIFIED_PASSPORT_UNIQUE_INDEX in detail