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 |
| Kind | AggregateRoot |
| Source of truth | profile |
| Таблица | profile.verification_requests |
| Tenant isolation | Не tenanted (ADR-0052: инвестор вне tenant); record-level по OwnerUserId; review-доступ — платформенный permission profile.verification.review |
Заявки не удаляются и не архивируются — это аудит-история KYC-решений (без soft-delete).
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | PK | app | Идентификатор заявки. |
OwnerUserId | Guid | Да | 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). |
PersonId | Guid | Да | FK fk_verification_requests_person_id → profile.persons(id), index | app | Персона-предмет проверки (Relation = Self — INV-VRF-3). |
RequisiteTemplateId | Guid? | Нет | FK fk_verification_requests_template_id → profile.requisite_templates(id) | app | Шаблон реквизитов, из которого предзаполнена заявка (данные заявки можно сохранить в шаблон реквизитов); информационная ссылка, состав фиксируется снапшотом. |
Status | VerificationRequestStatus | Да | varchar(32) (enum как текст), index ix_verification_requests_status | app | Draft / Submitted / InReview / Approved / Rejected. |
SnapshotCipher | byte[]? | Нет (обязателен со статуса Submitted — INV-VRF-2) | bytea | app (шифрование PII) | Зашифрованный JSON типизированного снапшота VerificationSnapshotModel (модель ниже), сформированного в момент Submit из персоны и её документов. Иммутабелен после Submit. |
SnapshotContractVersion | int? | Нет (обязателен вместе со снапшотом) | >= 1 | app | Версия модели снапшота. |
KeyVersion | int? | Нет (обязателен вместе со снапшотом) | >= 1 | app | Версия ключа шифрования снапшота. |
ScanList | DocumentScanList (jsonb) | Да (может быть пустым до Submit) | typed jsonb, модель — person-document.md; ≥ 1 элемента при Submit (INV-VRF-2) | app + storage | Сканы, приложенные непосредственно к заявке (дополнительно к сканам документов персоны, попавшим в снапшот). |
SubmittedAt | DateTimeOffset? | Нет | index ix_verification_requests_submitted_at | app | Момент Draft → Submitted (очередь ревью сортируется по нему, FIFO). |
ReviewStartedAt | DateTimeOffset? | Нет | — | app | Момент Submitted → InReview. |
ReviewerUserId | Guid? | Нет (обязателен со статуса InReview) | index ix_verification_requests_reviewer_user_id | app (actor-ревьюер) | Сотрудник ПИН (profile.verification.review), взявший заявку. |
CompletedAt | DateTimeOffset? | Нет | — | app | Момент Approved/Rejected. |
RejectionCode | VerificationRejectionCode? | Нет (обязателен при Rejected) | varchar(32) (enum как текст) | app (ревьюер) | Машиночитаемая причина отказа (enum ниже). |
RejectionReason | string? | Нет (обязателен при Rejected) | varchar(1024) | app (ревьюер) | Человекочитаемое пояснение для инвестора (без цитирования значений документов). |
DecisionComment | string? | Нет | varchar(1024) | app (ревьюер) | Внутренний комментарий ревьюера (инвестору не показывается). |
CreatedAt | DateTimeOffset | Да | index ix_verification_requests_created_at | app | Создание черновика. |
CreatedByUserId | Guid? | Нет | — | app | = OwnerUserId. |
UpdatedAt | DateTimeOffset | Да | — | app | Время последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший (инвестор либо ревьюер). |
Version | long | Да | >= 1 | app | Версия записи (оптимистическая блокировка). |
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 |
|---|---|---|---|---|
PersonId | Guid | Да | копия PersonId | v1 |
PersonVersion | long | Да | PersonEntity.Version на момент снапшота (сверка «данные не менялись») | v1 |
FirstName | string | Да | 1–128 символов | v1 |
LastName | string | Да | 1–128 символов | v1 |
MiddleName | string? | Нет | ≤ 128 символов | v1 |
BirthDate | DateOnly | Да | возраст ≥ 18 лет на момент Submit (INV-VRF-4) | v1 |
CitizenshipCountryId | short? | Нет | ссылка на справочник стран handbook | v1 |
IdentityDocument | DocumentSnapshot | Да | паспортный тип (INV-VRF-2) | v1 |
AdditionalDocuments | List<DocumentSnapshot> | Да (может быть пустым) | ≤ 10 элементов | v1 |
DocumentSnapshot (элемент снапшота)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|---|---|---|---|
DocumentId | Guid | Да | id исходного PersonDocumentEntity | v1 |
Type | PersonDocumentType | Да | enum из person-document.md | v1 |
CountryId | short | Да | ссылка на справочник стран handbook | v1 |
CountryIsoCode2 | string | Да | char(2) — юридический след: копия PersonDocumentEntity.CountryIsoCode2 на момент снапшота | v1 |
PayloadContractVersion | int | Да | версия typed-модели полей | v1 |
Payload | typed-модель по Type | Да | модели полей — person-document.md (дом, здесь не переописываются) | v1 |
Scans | List<DocumentScan> | Да (может быть пустым) | модель DocumentScan — person-document.md | v1 |
ScanList
Plaintext jsonb; модель DocumentScanList (ContractVersion = 1) — дом в
person-document.md, здесь без переописания.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|---|---|---|---|
PersonEntity | reference | PersonId (FK) | персона с документами загружается при отправке (сборка снапшота) | restrict (INV-PRS-5 в person.md) |
RequisiteTemplateEntity | reference | RequisiteTemplateId (FK, nullable) | шаблон загружается при создании заявки из шаблона | restrict не требуется (ссылка информационная; архив шаблона заявку не трогает) |
ProfileEntity | reference (владелец) | OwnerUserId → profiles.id | профиль загружается в guard-ах; при решении обновляется Profile.VerificationStatus в той же транзакции | restrict |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|---|---|---|
| INV-VRF-1 SingleActive | На OwnerUserId — не более одной заявки в статусах Draft/Submitted/InReview | unique partial index ux_verification_requests_owner_active + проверка при создании | PROFILE_VERIFICATION_ALREADY_IN_PROGRESS |
| INV-VRF-2 CompleteOnSubmit | Submit допустим, только если у персоны заполнены ФИО и дата рождения, есть активный паспортный документ, и суммарно ≥ 1 скана (в документах снапшота или ScanList) | при отправке (после сборки снапшота) | PROFILE_VERIFICATION_INCOMPLETE |
| INV-VRF-3 SelfPersonOnly | PersonId ссылается на активную персону 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.submit | guard investor-функций | PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE |
| INV-VRF-6 SingleReviewer | TakeInReview — только из Submitted; гонка двух ревьюеров разрешается оптимистической блокировкой: проигравший получает конфликт | при взятии в ревью (оптимистическая блокировка) | PROFILE_VERIFICATION_ALREADY_TAKEN |
| INV-VRF-7 ImmutableSnapshot | После Submit SnapshotCipher/ScanList не изменяются; доработка — только Revoke в Draft (снапшот обнуляется) и повторный Submit | правки запрещены вне Draft | PROFILE_VERIFICATION_NOT_EDITABLE |
| INV-VRF-8 DecisionByReviewer | Approve/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 → Submitted | Id, 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/Rejected | Id, 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 |
| Партиционирование / retention | none; заявки хранятся бессрочно (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 не должен цитировать значения документов (правило для ревьюера, контроль заботы) |
| Audit | Version + actor-stamping; полная история решений (терминальные записи не удаляются); доступы ревьюеров к снапшоту — события |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: PersonEntity, Entity / Aggregate: ProfileEntity, Entity / Aggregate: RequisiteTemplateEntity, Entity: PersonDocumentEntity
- API (12): GET /api/v1/admin/profile/verification-queue — Очередь ревью KYC (listVerificationQueue), GET /api/v1/admin/profile/verification/{requestId}/snapshot — Аудируемый просмотр снапшота KYC (viewVerificationSnapshot), GET /api/v1/internal/profile/verification-status/{userId} — Статус верификации для m2m (getVerificationStatus), GET /api/v1/profile/verification — Моя заявка на верификацию (getMyVerification), POST /api/v1/admin/profile/verification/{requestId}/approve — Одобрить заявку KYC (approveVerification), POST /api/v1/admin/profile/verification/{requestId}/reject — Отклонить заявку KYC (rejectVerification), POST /api/v1/admin/profile/verification/{requestId}/take — Взять заявку в ревью (takeVerificationInReview), POST /api/v1/profile/verification — Создание черновика заявки KYC (createVerificationRequest), POST /api/v1/profile/verification/{requestId}/revoke — Отзыв заявки KYC на доработку (revokeVerification), POST /api/v1/profile/verification/{requestId}/submit — Отправка заявки KYC на ревью (submitVerification), Profile API — реестр операций областей public / internal / admin, PUT /api/v1/profile/verification/{requestId} — Правка черновика заявки KYC (updateVerificationDraft)
- Use Cases (1): UC-PRF-003. Подача заявки на верификацию инвестора и её одобрение (KYC)
- События (2): Event Contract: pin.profile.verification-completed (VerificationCompletedKafkaEvent), Event Contract: pin.profile.verification-request-submitted (VerificationRequestSubmittedKafkaEvent)
- Фоновые задачи (1): Background Job: ReEncryptRotationJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Profile Service (Pin.Profile)
Связанные документы
- 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).