feat: delete double verify in one passport

This commit is contained in:
2026-05-22 01:33:17 +03:00
parent 2479bca17c
commit 31d097c3c2
8 changed files with 278 additions and 95 deletions

View File

@@ -1 +0,0 @@
ALTER TABLE users ADD COLUMN avatar_link TEXT;

View File

@@ -20,6 +20,11 @@ class IUserRepository(ABC):
raise NotImplementedError raise NotImplementedError
@abstractmethod
async def exists_verified_by_passport(self,*,passport_data: str,exclude_user_id: str) -> bool:
raise NotImplementedError
@abstractmethod @abstractmethod
async def update_kyc_data( async def update_kyc_data(
self, self,

View File

@@ -1,12 +1,13 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime,timezone,timedelta from datetime import datetime,timezone,timedelta
from typing import Any
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,KycPersonalData,KycSessionResponse
from src.application.domain.entities import KycEntity from src.application.domain.entities import KycEntity
from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycSessionExpiredException,KycSessionMissingUserTokenException from src.application.domain.exceptions import ApplicationException,KycFailedException,KycNotCompletedException,KycPassportAlreadyVerifiedException,KycPersonalDataIncompleteException,KycSessionExpiredException,KycSessionMissingUserTokenException
from src.application.services import ensure_adult,extract_personal_data,parse_birth_date 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: def _kyc_started_entity_to_create_response(entity: KycEntity) -> BeorgKycCreateResponse:
@@ -105,28 +106,15 @@ class CompleteKycCommand:
raise KycFailedException() raise KycFailedException()
personal_data = extract_personal_data(result.data) personal_data = extract_personal_data(result.data)
birth_date = parse_birth_date(personal_data.birth_date) await _complete_kyc_session(
ensure_adult(birth_date) unit_of_work=self._unit_of_work,
user_id=user_id,
async with self._unit_of_work as unit_of_work: user_token=session.user_token,
await unit_of_work.user_repository.update_kyc_data( done_state=result.done_state,
user_id=user_id, set_id=result.set_id,
first_name=personal_data.first_name, result_data=result.data,
last_name=personal_data.last_name, personal_data=personal_data,
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,
)
self._logger.info(f'KYC completed for user {user_id}') self._logger.info(f'KYC completed for user {user_id}')
return result return result
@@ -236,30 +224,59 @@ class PollKycSessionsCommand:
return return
personal_data = extract_personal_data(result.data) personal_data = extract_personal_data(result.data)
birth_date = parse_birth_date(personal_data.birth_date) await _complete_kyc_session(
ensure_adult(birth_date) unit_of_work=self._unit_of_work,
user_id=user_id,
async with self._unit_of_work as unit_of_work: user_token=user_token,
await unit_of_work.user_repository.update_kyc_data( done_state=result.done_state,
user_id=user_id, set_id=result.set_id,
first_name=personal_data.first_name, result_data=result.data,
last_name=personal_data.last_name, personal_data=personal_data,
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,
)
self._logger.info(f'KYC completed for user {user_id}') 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: def _utc_now() -> datetime:
return datetime.now(timezone.utc) return datetime.now(timezone.utc)

View File

@@ -1,2 +1,2 @@
from src.application.domain.exceptions.application_exceptions import ApplicationException 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 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

View File

@@ -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') 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): class BeorgUnavailableException(ApplicationException):
def __init__(self) -> None: def __init__(self) -> None:

View File

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

View File

@@ -1,5 +1,5 @@
from __future__ import annotations from __future__ import annotations
from datetime import date,datetime from datetime import date,datetime,timezone
from typing import Any from typing import Any
from src.application.domain.dto import KycPersonalData from src.application.domain.dto import KycPersonalData
from src.application.domain.exceptions import KycAgeRestrictedException,KycBirthDateInvalidException,KycPersonalDataIncompleteException 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'), middle_name=values.get('middle_name'),
birth_date=str(_parse_date(values['birth_date'])), birth_date=str(_parse_date(values['birth_date'])),
inn=values.get('inn'), 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: def _try_extract_beorg_documents(data: Any) -> KycPersonalData | None:
if isinstance(data,dict) and 'documents' not in data: if isinstance(data,dict) and 'documents' not in data:
inner = data.get('data') for inner_key in ('data','recognition','Recognition','RecognitionResult','recognition_result','payload'):
if isinstance(inner,dict): inner = data.get(inner_key)
nested = _try_extract_beorg_documents(inner) if isinstance(inner,dict):
if nested is not None: nested = _try_extract_beorg_documents(inner)
return nested if nested is not None:
return nested
if not isinstance(data,dict): if not isinstance(data,dict):
return None return None
documents = data.get('documents') documents = data.get('documents')
if not isinstance(documents,list) or not documents: if not isinstance(documents,list) or not documents:
return None return None
passport: dict[str,Any] | None = None prioritized: list[dict[str,Any]] = []
rest: list[dict[str,Any]] = []
for item in documents: for item in documents:
if not isinstance(item,dict): if not isinstance(item,dict):
continue continue
dtype = str(item.get('type') or '').strip().upper() dtype = str(item.get('type') or '').strip().upper()
dkey = str(item.get('key') or '').strip().upper() dkey = str(item.get('key') or '').strip().upper()
if dtype == 'PASSPORT' or 'PASSPORT' in dtype or dkey.startswith('PASSPORT'): if dtype == 'PASSPORT' or 'PASSPORT' in dtype or dkey.startswith('PASSPORT'):
passport = item prioritized.append(item)
break else:
rest.append(item)
if passport is None: for item in prioritized + rest:
for item in documents: pdata = item.get('data')
if not isinstance(item,dict): if not isinstance(pdata,dict):
continue continue
pdata = item.get('data') meta = item.get('metadata')
if isinstance(pdata,dict) and any(k in pdata for k in ('LastName','FirstName','Series','Number')): parsed = _personal_from_passport_pdata(pdata,meta if isinstance(meta,dict) else None)
passport = item if parsed is not None:
break 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')) def _personal_from_passport_pdata(
first_name = _norm_text(pdata.get('FirstName'),pdata.get('first_name')) 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') middle_raw = pdata.get('MiddleName')
if middle_raw is None: if middle_raw is None:
middle_raw = pdata.get('middle_name') middle_raw = pdata.get('middle_name')
if middle_raw is None:
middle_raw = pdata.get('Patronymic')
ms = _norm_scalar(middle_raw) ms = _norm_scalar(middle_raw)
middle_name = ms if ms else None 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_date_raw = birth_ts if birth_ts else '' 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() series = (_norm_scalar(pdata.get('Series')) or '').strip()
number = (_norm_scalar(pdata.get('Number')) 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 inn_val = _extract_inn_from_metadata(metadata)
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: if not first_name or not last_name or not birth_date_raw:
return None 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: if value is None:
return None return None
s = str(value).strip() if isinstance(value,str):
return s if s else None 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: def _norm_text(*candidates: Any) -> str:
for cand in candidates: for cand in candidates:
s = _norm_scalar(cand) s = _field_as_text(cand)
if s: if s:
return s return s
return '' 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: def ensure_adult(birth_date: date) -> None:
today = date.today() today = date.today()
try: try:
@@ -156,7 +247,12 @@ def _walk(data: Any) -> list[tuple[str,Any]]:
items: list[tuple[str,Any]] = [] items: list[tuple[str,Any]] = []
if isinstance(data,dict): if isinstance(data,dict):
for key,value in data.items(): 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)) items.extend(_walk(value))
else: else:
items.append((str(key),value)) items.append((str(key),value))
@@ -172,6 +268,25 @@ def _normalize_key(key: str) -> str:
def _parse_date(value: str) -> date: def _parse_date(value: str) -> date:
clean = value.strip() 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') formats = ('%Y-%m-%d','%d.%m.%Y','%d-%m-%Y','%d/%m/%Y','%Y.%m.%d')
for date_format in formats: for date_format in formats:
try: try:

View File

@@ -1,13 +1,17 @@
from __future__ import annotations from __future__ import annotations
from datetime import date,datetime,timezone 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 sqlalchemy.ext.asyncio import AsyncSession
from src.application.abstractions.repositories import IUserRepository from src.application.abstractions.repositories import IUserRepository
from src.application.domain.entities import UserEntity 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 from src.infrastructure.database.models.user import UserModel
VERIFIED_PASSPORT_UNIQUE_INDEX = 'users_verified_passport_unique_idx'
class UserRepository(IUserRepository): class UserRepository(IUserRepository):
def __init__(self,session: AsyncSession) -> None: def __init__(self,session: AsyncSession) -> None:
@@ -34,6 +38,20 @@ class UserRepository(IUserRepository):
return result.scalar_one_or_none() is not None 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( async def update_kyc_data(
self, self,
*, *,
@@ -57,7 +75,12 @@ class UserRepository(IUserRepository):
user.passport_data = passport_data user.passport_data = passport_data
user.kyc_verified = True user.kyc_verified = True
user.kyc_verified_at = datetime.now(timezone.utc) 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) return self._to_entity(user)
@@ -82,3 +105,17 @@ class UserRepository(IUserRepository):
updated_at=user.updated_at, updated_at=user.updated_at,
kyc_verified_at=user.kyc_verified_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