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

View File

@@ -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,27 +106,14 @@ 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(
await _complete_kyc_session(
unit_of_work=self._unit_of_work,
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,
personal_data=personal_data,
)
self._logger.info(f'KYC completed for user {user_id}')
return result
@@ -236,29 +224,58 @@ class PollKycSessionsCommand:
return
personal_data = extract_personal_data(result.data)
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 self._unit_of_work as unit_of_work:
await unit_of_work.user_repository.update_kyc_data(
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=personal_data.passport_data,
passport_data=passport_data,
)
await unit_of_work.kyc_repository.update_session_result(
await uow.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,
done_state=done_state,
set_id=set_id,
result_data=result_data,
error=None,
)
self._logger.info(f'KYC completed for user {user_id}')
def _utc_now() -> datetime:

View File

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

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 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,13 +45,14 @@ 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')
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:
@@ -62,53 +63,78 @@ def _try_extract_beorg_documents(data: Any) -> KycPersonalData | None:
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
for item in prioritized + rest:
pdata = item.get('data')
if isinstance(pdata,dict) and any(k in pdata for k in ('LastName','FirstName','Series','Number')):
passport = item
break
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
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()
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:

View File

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