feat: delete double verify in one passport
This commit is contained in:
@@ -1 +0,0 @@
|
||||
ALTER TABLE users ADD COLUMN avatar_link TEXT;
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user