Перейти к основному содержимому

Entity / Aggregate: VerificationRequestEntity

Дом полей агрегата «Заявка на верификацию инвестора» контекста profile. Payload событий pin.profile.verification-requests.v1 и денормализованный Profile.VerificationStatus ссылаются сюда.

Назначение

VerificationRequestEntity — заявка на простую верификацию/квалификацию инвестора (KYC/AML): инвестор заполняет информацию о себе — ФИО, дату рождения, паспортные данные, — прикрепляет сканы и отправляет на рассмотрение сотруднику ПИН. Данные заявки подкрепляются к учётной записи через персону (person.md) и, опционально, к шаблону реквизитов (requisite-template.md); на момент отправки формируется иммутабельный зашифрованный снапшот данных (решение принимается по тому, что видел ревьюер, даже если персона потом изменилась).

Верифицируется всегда учётная запись (OwnerUserId), предмет проверки — персона Relation = Self (верификация чужих персон не выполняется — INV-VRF-3). Только пользователи типа Investor.

Классификация

ПолеЗначение
Контекстprofile
KindAggregateRoot
Source of truthprofile
Таблицаprofile.verification_requests
Tenant isolationНе tenanted (ADR-0052: инвестор вне tenant); record-level по OwnerUserId; review-доступ — платформенный permission profile.verification.review

Заявки не удаляются и не архивируются — это аудит-история KYC-решений (без soft-delete).

Поля

ПолеТипОбязательностьОграничения (PK/FK/index/constraint)ИсточникОписание
IdGuidДаPKappИдентификатор заявки.
OwnerUserIdGuidДаindex ix_verification_requests_owner_user_id; unique partial ux_verification_requests_owner_active (owner_user_id) where status in ('Draft','Submitted','InReview')app (actor)Верифицируемая учётная запись (инвестор). Одна незавершённая заявка на аккаунт (INV-VRF-1).
PersonIdGuidДаFK fk_verification_requests_person_id → profile.persons(id), indexappПерсона-предмет проверки (Relation = Self — INV-VRF-3).
RequisiteTemplateIdGuid?НетFK fk_verification_requests_template_id → profile.requisite_templates(id)appШаблон реквизитов, из которого предзаполнена заявка (данные заявки можно сохранить в шаблон реквизитов); информационная ссылка, состав фиксируется снапшотом.
StatusVerificationRequestStatusДаvarchar(32) (enum как текст), index ix_verification_requests_statusappDraft / Submitted / InReview / Approved / Rejected.
SnapshotCipherbyte[]?Нет (обязателен со статуса Submitted — INV-VRF-2)byteaapp (шифрование PII)Зашифрованный JSON типизированного снапшота VerificationSnapshotModel (модель ниже), сформированного в момент Submit из персоны и её документов. Иммутабелен после Submit.
SnapshotContractVersionint?Нет (обязателен вместе со снапшотом)>= 1appВерсия модели снапшота.
KeyVersionint?Нет (обязателен вместе со снапшотом)>= 1appВерсия ключа шифрования снапшота.
ScanListDocumentScanList (jsonb)Да (может быть пустым до Submit)typed jsonb, модель — person-document.md; ≥ 1 элемента при Submit (INV-VRF-2)app + storageСканы, приложенные непосредственно к заявке (дополнительно к сканам документов персоны, попавшим в снапшот).
SubmittedAtDateTimeOffset?Нетindex ix_verification_requests_submitted_atappМомент Draft → Submitted (очередь ревью сортируется по нему, FIFO).
ReviewStartedAtDateTimeOffset?НетappМомент Submitted → InReview.
ReviewerUserIdGuid?Нет (обязателен со статуса InReview)index ix_verification_requests_reviewer_user_idapp (actor-ревьюер)Сотрудник ПИН (profile.verification.review), взявший заявку.
CompletedAtDateTimeOffset?НетappМомент Approved/Rejected.
RejectionCodeVerificationRejectionCode?Нет (обязателен при Rejected)varchar(32) (enum как текст)app (ревьюер)Машиночитаемая причина отказа (enum ниже).
RejectionReasonstring?Нет (обязателен при Rejected)varchar(1024)app (ревьюер)Человекочитаемое пояснение для инвестора (без цитирования значений документов).
DecisionCommentstring?Нетvarchar(1024)app (ревьюер)Внутренний комментарий ревьюера (инвестору не показывается).
CreatedAtDateTimeOffsetДаindex ix_verification_requests_created_atappСоздание черновика.
CreatedByUserIdGuid?Нетapp= OwnerUserId.
UpdatedAtDateTimeOffsetДаappВремя последнего изменения.
UpdatedByUserIdGuid?НетappПоследний изменивший (инвестор либо ревьюер).
VersionlongДа>= 1appВерсия записи (оптимистическая блокировка).

Enum VerificationRequestStatus

ЗначениеОписание
DraftЧерновик: инвестор заполняет данные персоны/прикладывает сканы; снапшота ещё нет.
SubmittedОтправлена: снапшот зафиксирован, заявка в очереди ревью; правки запрещены (только отзыв в Draft).
InReviewВзята ревьюером (ReviewerUserId); отзыв инвестором запрещён.
ApprovedОдобрена: аккаунт верифицирован (Profile.VerificationStatus = Verified). Терминальный.
RejectedОтклонена с RejectionCode+RejectionReason; инвестор может создать новую заявку. Терминальный.

Enum VerificationRejectionCode

ЗначениеОписание
IllegibleScansСканы нечитаемы / неполные.
DataMismatchДанные формы не совпадают с документами.
DocumentExpiredСрок действия документа истёк.
SuspectedFraudПодозрение на подделку/мошенничество (AML).
OtherИное (обязателен развёрнутый RejectionReason).

Value objects

VO не используются: данные заявки — в зашифрованном снапшоте.

Модели JSONB (versioned)

VerificationSnapshotModel (шифруется в SnapshotCipher, ContractVersion = 1)

Строится при отправке (Submit) из агрегата персоны (расшифровка полей персоны → сборка модели → сериализация → шифрование одним ключом KeyVersion). После Submit не изменяется.

ПолеТипОбязательностьОграниченияSince version
PersonIdGuidДакопия PersonIdv1
PersonVersionlongДаPersonEntity.Version на момент снапшота (сверка «данные не менялись»)v1
FirstNamestringДа1–128 символовv1
LastNamestringДа1–128 символовv1
MiddleNamestring?Нет≤ 128 символовv1
BirthDateDateOnlyДавозраст ≥ 18 лет на момент Submit (INV-VRF-4)v1
CitizenshipCountryIdshort?Нетссылка на справочник стран handbookv1
IdentityDocumentDocumentSnapshotДапаспортный тип (INV-VRF-2)v1
AdditionalDocumentsList<DocumentSnapshot>Да (может быть пустым)≤ 10 элементовv1

DocumentSnapshot (элемент снапшота)

ПолеТипОбязательностьОграниченияSince version
DocumentIdGuidДаid исходного PersonDocumentEntityv1
TypePersonDocumentTypeДаenum из person-document.mdv1
CountryIdshortДассылка на справочник стран handbookv1
CountryIsoCode2stringДаchar(2) — юридический след: копия PersonDocumentEntity.CountryIsoCode2 на момент снапшотаv1
PayloadContractVersionintДаверсия typed-модели полейv1
Payloadtyped-модель по TypeДамодели полей — person-document.md (дом, здесь не переописываются)v1
ScansList<DocumentScan>Да (может быть пустым)модель DocumentScanperson-document.mdv1

ScanList

Plaintext jsonb; модель DocumentScanList (ContractVersion = 1) — дом в person-document.md, здесь без переописания.

Связи

СвязьТипКлючПравило загрузкиDelete behavior
PersonEntityreferencePersonId (FK)персона с документами загружается при отправке (сборка снапшота)restrict (INV-PRS-5 в person.md)
RequisiteTemplateEntityreferenceRequisiteTemplateId (FK, nullable)шаблон загружается при создании заявки из шаблонаrestrict не требуется (ссылка информационная; архив шаблона заявку не трогает)
ProfileEntityreference (владелец)OwnerUserId → profiles.idпрофиль загружается в guard-ах; при решении обновляется Profile.VerificationStatus в той же транзакцииrestrict

Инварианты и переходы состояний

ИнвариантУсловиеГде проверяетсяОшибка
INV-VRF-1 SingleActiveНа OwnerUserId — не более одной заявки в статусах Draft/Submitted/InReviewunique partial index ux_verification_requests_owner_active + проверка при созданииPROFILE_VERIFICATION_ALREADY_IN_PROGRESS
INV-VRF-2 CompleteOnSubmitSubmit допустим, только если у персоны заполнены ФИО и дата рождения, есть активный паспортный документ, и суммарно ≥ 1 скана (в документах снапшота или ScanList)при отправке (после сборки снапшота)PROFILE_VERIFICATION_INCOMPLETE
INV-VRF-3 SelfPersonOnlyPersonId ссылается на активную персону Relation = Self владельцапри создании заявкиPROFILE_VERIFICATION_PERSON_NOT_SELF
INV-VRF-4 AdultOnlyВозраст по BirthDate ≥ 18 лет на момент Submit (KYC)при отправкеPROFILE_VERIFICATION_UNDERAGE
INV-VRF-5 InvestorOnlyЗаявку создаёт только Profile.UserType = Investor c permission profile.verification.submitguard investor-функцийPROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE
INV-VRF-6 SingleReviewerTakeInReview — только из Submitted; гонка двух ревьюеров разрешается оптимистической блокировкой: проигравший получает конфликтпри взятии в ревью (оптимистическая блокировка)PROFILE_VERIFICATION_ALREADY_TAKEN
INV-VRF-7 ImmutableSnapshotПосле Submit SnapshotCipher/ScanList не изменяются; доработка — только Revoke в Draft (снапшот обнуляется) и повторный Submitправки запрещены вне DraftPROFILE_VERIFICATION_NOT_EDITABLE
INV-VRF-8 DecisionByReviewerApprove/Reject — только actor = ReviewerUserId (или супервизор заботы с тем же permission — фиксируется в UpdatedByUserId); Reject требует RejectionCode + RejectionReasonпри решении ревьюераPROFILE_VERIFICATION_NOT_REVIEWER / ValidationError
INV-VRF-9 AlreadyVerifiedНовая заявка запрещена при Profile.VerificationStatus = Verifiedпри создании заявкиPROFILE_ALREADY_VERIFIED

Отказы шифрования и терминальность решения (fail-closed). Сборка снапшота при Submit и его расшифровка при ревью зависят от доступности ключей шифрования ПДн. Если ключ недоступен: на Submit снапшот не формируется — статус остаётся Draft, Profile.VerificationStatus не меняется (частичного перехода нет, инвестор повторяет позже); при ревью расшифровка снапшота недоступна — ревьюер видит ошибку и не принимает решение (заявка остаётся InReview, решение «вслепую» невозможно). Оба терминальных перехода (Approve/Reject) допустимы только из InReview: повторный или конкурентный вызов решения по заявке, уже переведённой в Approved/Rejected, отклоняется как операция над недопустимым статусом — по одной заявке решение фиксируется ровно один раз (терминальные записи не переоткрываются; после Rejected цикл начинается новой заявкой с новым Id). Из InReview нет автоматического возврата в очередь: заявка остаётся закреплённой за ReviewerUserId до решения; завершить её вправе тот же ревьюер или супервизор с profile.verification.review (INV-VRF-8).

Доменные и интеграционные события

СобытиеТипКогдаSnapshot/поля
VerificationRequestCreatedEventдоменноеСоздание черновикаId, OwnerUserId, PersonId
VerificationRequestSubmittedEventдоменноеDraft → SubmittedId, OwnerUserId, SubmittedAt
VerificationRequestDecidedEventдоменноеApproved/Rejected; внутри контекста обновляет Profile.VerificationStatus в той же транзакцииId, OwnerUserId, Status, RejectionCode?
VerificationRequestSubmittedKafkaEventинтеграционное (ContractVersion = 1, topic pin.profile.verification-requests.v1, key = OwnerUserId)Draft → SubmittedБез PII: Id, OwnerUserId, SubmittedAt. Потребители: notifications (уведомление заботе/очередь), support (тикет-очередь ревью).
VerificationCompletedKafkaEventинтеграционное (тот же topic, ContractVersion = 1, key = OwnerUserId)Approved/RejectedId, OwnerUserId, Status (Approved/Rejected), RejectionCode?, CompletedAt. Потребители: notifications (уведомление инвестору), crm/catalog (бейдж «верифицирован», доступ к функциям для верифицированных).

Контракты — будущие документы ../events/verification-request-submitted.md, ../events/verification-completed.md. Интеграционные события публикуются через транзакционный outbox атомарно с решением по заявке (одна транзакция: заявка + ProfileEntity + событие).

Индексы, хранение, безопасность

АспектЗначение
ИндексыPK (Id); ux_verification_requests_owner_active unique (owner_user_id) where status in ('Draft','Submitted','InReview'); ix_verification_requests_owner_user_id; ix_verification_requests_status; ix_verification_requests_submitted_at (очередь ревью: status='Submitted' order by submitted_at); ix_verification_requests_reviewer_user_id; ix_verification_requests_created_at
Партиционирование / retentionnone; заявки хранятся бессрочно (KYC-аудит; без soft-delete)
Concurrencyоптимистическая блокировка (правка не должна затирать чужую) — гонки ревьюеров (INV-VRF-6) и «Submit vs Revoke»
Permissions / PIIСнапшот — только шифротекст (PersonalDataCrypto); расшифровка — self-просмотр (profile.verification.submit + владение) и ревью (profile.verification.review, каждый доступ ревьюера к расшифровке аудируется отдельной записью доменного события VerificationSnapshotAccessedEvent(Id, ReviewerUserId, AccessedAt)); в Kafka/логах значения не публикуются; RejectionReason не должен цитировать значения документов (правило для ревьюера, контроль заботы)
AuditVersion + actor-stamping; полная история решений (терминальные записи не удаляются); доступы ревьюеров к снапшоту — события

Обратные ссылки

Автоссылки: где используется этот документ.

Связанные документы

  • person.md, person-document.md (typed-модели payload — дом), requisite-template.md, profile.md (денормализация VerificationStatus).
  • Потребители событий: docs/06-services/notifications/, docs/06-services/support/ (очередь ревью — по будущим ТЗ этих контекстов), docs/06-services/crm/.
  • Матрица «Инвестор» раздел 1 (auth/профиль/верификация).
  • ADR-0052, ADR-0056; permissions — permission.md (profile.verification.submit / profile.verification.review).