UC-PRF-003. Подача заявки на верификацию инвестора и её одобрение (KYC)
Назначение
Полный цикл простой верификации инвестора (KYC/AML): создание черновика заявки, заполнение (ФИО, дата
рождения, паспортные данные через персону Self, сканы), отправка с фиксацией иммутабельного
шифрованного снапшота, ревью сотрудником ПИН и одобрение с обновлением
Profile.VerificationStatus = Verified. Связан с контекстом profile.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-PRF-003 |
| Контекст | profile (см. docs/06-services/profile/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инвестор (подача, отзыв) — только UserType = Investor (INV-VRF-5); Сотрудник ПИН — ревьюер (очередь, решение) |
| Permissions / scope | Инвестор: profile.verification.submit (self-scope). Ревьюер: profile.verification.review (платформенный scope, admin-area API) |
| Tenant | не tenanted (ADR-0052); заявки инвестора — record-level по OwnerUserId; ревьюер — кросс-record платформенный permission |
| Record-level | инвестор — только свои заявки; ревьюер — вся очередь Submitted + взятые им InReview |
Предусловие
- Выполнены UC-PRF-001/002: у инвестора есть активная персона
Relation = Selfс заполненными ФИО и датой рождения и активным паспортным документом (тип из{RfPassport, RbPassport, KzPassport, CisPassport, ForeignPassport}). Profile.VerificationStatus != Verified(INV-VRF-9) и нет заявки вDraft/Submitted/InReview(INV-VRF-1).- Feature flag
profile.verification.enabledвключён.
Триггер
| Поле | Значение |
|---|---|
| Источник | UI button |
| Триггерящий элемент | ЛК → «Верификация инвестора» → «Пройти верификацию»; интерфейс ревьюера ПИН → «Очередь верификаций» |
Основной Поток
- Инвестор нажимает «Пройти верификацию». Фронт вызывает
createVerificationRequest(POST /profile/verification-requests) сCreateVerificationRequestForm { requisiteTemplateId? (Guid — предзаполнение из шаблона) }. - Создание черновика.
- Проверить доступ по профилю (INV-PRF-2/3) и тип пользователя — инвестор (INV-VRF-5, иначе
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE). - Проверить, что инвестор ещё не верифицирован:
VerificationStatus != Verified(INV-VRF-9, иначеPROFILE_ALREADY_VERIFIED). - Проверить отсутствие активной заявки: нет заявки в статусе
Draft/Submitted/InReview(INV-VRF-1, иначеPROFILE_VERIFICATION_ALREADY_IN_PROGRESS; финальный арбитр гонки — уникальный индексux_verification_requests_owner_active). - Найти активную персону
Relation = Self(Status = Active); отсутствует →PROFILE_VERIFICATION_PERSON_NOT_SELF(INV-VRF-3). - При переданном
requisiteTemplateId— шаблон принадлежит актору,Status = Active,template.PersonIdуказывает на персону Self. - Создать
VerificationRequestEntity(OwnerUserId = UserId,PersonId = selfPerson.Id,RequisiteTemplateId):Status = Draft;SnapshotCipher = null;ScanList = { ContractVersion: 1, Items: [] };CreatedByUserId = UserId. Публикуется доменноеVerificationRequestCreatedEvent(Id, OwnerUserId, PersonId)(Kafka — только на Submit). Результат —VerificationRequestResult { id, status: Draft, personId }.
- Проверить доступ по профилю (INV-PRF-2/3) и тип пользователя — инвестор (INV-VRF-5, иначе
- Инвестор проверяет/дополняет данные персоны Self (форма заявки редактирует персону — UC-PRF-001
шаги 5–6) и прикладывает дополнительные сканы к заявке:
updateVerificationDraft(PUT /profile/verification-requests/{id}) сUpdateVerificationDraftForm { scans: List<DocumentScanForm { fileId, fileName, contentType, sizeBytes }> ≤ 10 }. Разрешено только вStatus = Draft(INV-VRF-7, иначеPROFILE_VERIFICATION_NOT_EDITABLE), для владельца; валидируетсяDocumentScanList; сканы заявки заменяются целиком. - Инвестор нажимает «Отправить на проверку». Фронт вызывает
submitVerification(POST /profile/verification-requests/{id}/submit, без тела). - Отправка на проверку.
- Заявка принадлежит актору,
Status = Draft; доступ по профилю (INV-PRF-2/3, INV-VRF-5). - Собрать данные персоны Self с документами и расшифровать ФИО и
BirthDate. - Проверить полноту (INV-VRF-2): ФИО и
BirthDateзаполнены; есть активный паспортный документ; суммарно ≥ 1 скана (сканы документов снапшота +request.ScanList) — иначеPROFILE_VERIFICATION_INCOMPLETE. - Проверить возраст ≥ 18 лет на момент Submit (INV-VRF-4, иначе
PROFILE_VERIFICATION_UNDERAGE). - Собрать
VerificationSnapshotModel v1:PersonId,PersonVersion = person.Version, ФИО,BirthDate,CitizenshipCountryId,IdentityDocument = DocumentSnapshot { DocumentId, Type, CountryId, CountryIsoCode2 (юридический след), PayloadContractVersion, Payload (расшифрованный), Scans }(паспортный документ; при нескольких — выбранный в форме, по умолчанию самый свежий поIssueDate),AdditionalDocuments— остальные активные документы (≤ 10). - Зашифровать снапшот целиком:
SnapshotCipher,SnapshotContractVersion = 1,KeyVersion= текущий. - Переход
Draft → Submitted;SubmittedAt = now; снапшот далее иммутабелен (INV-VRF-7). - В том же согласованном изменении
Profile.VerificationStatus = Pending(INV-PRF-6). Публикуются доменноеVerificationRequestSubmittedEvent(Id, OwnerUserId, SubmittedAt)и интеграционноеVerificationRequestSubmittedKafkaEvent(ContractVersion = 1, топикpin.profile.verification-requests.v1, key =OwnerUserId; payload без PII:Id,OwnerUserId,SubmittedAt) — потребителиnotifications(очередь заботе) иsupport(тикет ревью). Результат —VerificationRequestResult { id, status: Submitted, submittedAt }.
- Заявка принадлежит актору,
- Ревьюер ПИН открывает очередь:
listVerificationQueue(GET /profile/admin/verification-requests ?status=Submitted) — заявкиStatus = Submitted, порядок поSubmittedAt(FIFO). Берёт заявку:takeVerificationInReview(POST .../{id}/take): только изSubmitted;ReviewerUserId = actor,ReviewStartedAt = now,Status = InReview. Гонка двух ревьюеров разрешается оптимистической блокировкой по версии записи: проигравший получаетPROFILE_VERIFICATION_ALREADY_TAKEN(INV-VRF-6). - Ревьюер смотрит данные:
viewVerificationSnapshot(GET .../{id}/snapshot) — доступ только уReviewerUserId(или супервизора заботы сprofile.verification.review); снапшот расшифровывается, и каждый доступ аудируется доменным событиемVerificationSnapshotAccessedEvent(Id, ReviewerUserId, AccessedAt); сканы — presigned-ссылки storage поFileId. - Ревьюер одобряет:
approveVerification(POST .../{id}/approve, телоApproveVerificationForm { decisionComment? (≤1024) }).- Заявка
Status = InReview; решение принимает тот же ревьюерactor = ReviewerUserId(INV-VRF-8, иначеPROFILE_VERIFICATION_NOT_REVIEWER). - Переход
Status = Approved,CompletedAt = now,DecisionComment← форма. В том же согласованном измененииProfile.VerificationStatus = Verified,VerifiedAt = now,VerifiedRequestId = request.Id(INV-PRF-6). Публикуются доменноеVerificationRequestDecidedEvent(Id, OwnerUserId, Approved)и интеграционноеVerificationCompletedKafkaEvent(ContractVersion = 1, тот же топик, key =OwnerUserId; payload:Id,OwnerUserId,Status = Approved,RejectionCode = null,CompletedAt) — потребителиnotifications(уведомление инвестору),crm/catalog(бейдж «верифицирован»). Интерфейс ревьюера убирает заявку из очереди.
- Заявка
- Инвестор видит в ЛК статус «Верифицирован» (
getMyVerification/getMyProfile);notificationsдоставляет уведомление по событию.
Альтернативные Потоки
A1. Отклонение заявки (Reject)
- Вместо шага 8 ревьюер вызывает
rejectVerification(POST .../{id}/reject) сRejectVerificationForm { rejectionCode (IllegibleScans|DataMismatch|DocumentExpired| SuspectedFraud|Other), rejectionReason (1..1024 — обязателен; без цитирования значений документов), decisionComment? (≤1024, внутренний) }. - Reject требует code + reason (при
Other— развёрнутый reason); переходStatus = Rejected,CompletedAt = now; в том же согласованном измененииProfile.VerificationStatus = Rejected; публикуетсяVerificationCompletedKafkaEvent { Status = Rejected, RejectionCode }(INV-VRF-8). - Инвестор видит причину (
RejectionReason;DecisionCommentне показывается) и может создать новую заявку (шаг 1 основного потока; терминальнаяRejectedне блокирует INV-VRF-1).
A2. Отзыв заявки инвестором (Revoke)
- Пока заявка в
Submitted(ещё не взята ревьюером), инвестор вызываетrevokeVerification(POST .../{id}/revoke):Submitted → Draft,SnapshotCipher/SnapshotContractVersion/KeyVersion/SubmittedAtобнуляются (INV-VRF-7 — доработка только через Draft);Profile.VerificationStatusвозвращается вNotVerified(илиRejected, если предыдущая была отклонена — по машине состояний ../domain/profile.md). - Из
InReviewотзыв запрещён →PROFILE_VERIFICATION_NOT_EDITABLE(409). Гонка «Revoke vs TakeInReview» разрешается оптимистической блокировкой по версии записи. Возврат к шагу 3 основного потока.
A3. Неполные данные при Submit
PROFILE_VERIFICATION_INCOMPLETE(INV-VRF-2): нет ФИО/даты рождения, нет активного паспортного документа или ни одного скана. Фронт подсвечивает недостающее и ведёт в форму персоны (UC-PRF-001/002). Заявка остаётся вDraft. Возврат к шагу 3.PROFILE_VERIFICATION_UNDERAGE(INV-VRF-4): возраст < 18 — заявка не отправляется, сценарий завершается.
A4. Гонка двух ревьюеров
- Оба вызывают
takeVerificationInReview; первый успешно сохраняет, второй получает конфликт версии записи →PROFILE_VERIFICATION_ALREADY_TAKEN(409, INV-VRF-6). - Интерфейс ревьюера обновляет очередь. Возврат к шагу 6.
A5. Повторная заявка уже верифицированного
createVerificationRequest→PROFILE_ALREADY_VERIFIED(409, INV-VRF-9). Фронт показывает бейдж «Верифицирован». Сценарий завершается.
A6. Ключ шифрования недоступен при сборке/расшифровке снапшота (fail-closed)
- На
submitVerificationснапшот не собирается: заявка остаётсяDraft,Profile.VerificationStatusне меняется, событие не публикуется (INTERNAL, 500; alert «ошибки шифрования ПДн»). Частичного переходаDraft → Submittedнет; инвестор повторяет отправку после восстановления ключей. - На
viewVerificationSnapshotрасшифровкаSnapshotCipherнедоступна — ревьюер видит ошибку и не принимает решение; заявка остаётсяInReview(решение по нерасшифрованным данным невозможно). Возврат к шагу 7 после восстановления.
A7. Заявка «зависла» в InReview (ревьюер не завершил рассмотрение)
- Из
InReviewнет автоматического возврата в очередь: заявка остаётся закреплённой заReviewerUserIdдо решения. Инвестор в этот период видит статус «На проверке» (Pending); отзыв изInReviewзапрещён (A2,PROFILE_VERIFICATION_NOT_EDITABLE). - Завершить рассмотрение вправе тот же ревьюер или супервизор заботы с
profile.verification.review(INV-VRF-8 допускает решение супервизором; фиксируется вUpdatedByUserId). Возврат к шагу 8/A1.
Постусловие
- Успех:
VerificationRequestEntity.Status = Approved(терминальный, не удаляется — KYC-аудит);Profile.VerificationStatus = Verified,VerifiedAt/VerifiedRequestIdзаполнены;VerificationCompletedKafkaEventопубликован; ЛК и потребители (crm/catalog) видят бейдж. - Снапшот заявки иммутабелен и зашифрован; решение навсегда привязано к данным на момент Submit
(
PersonVersion— сверка «данные не менялись»). - Все доступы ревьюера к расшифрованному снапшоту зафиксированы
VerificationSnapshotAccessedEvent.
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
ValidationError | 400 | Сканы/форма reject (обязательность code+reason) | Подсветить поля |
PROFILE_ALREADY_VERIFIED | 409 | INV-VRF-9 | Показать бейдж, скрыть кнопку |
PROFILE_VERIFICATION_ALREADY_IN_PROGRESS | 409 | INV-VRF-1 | Открыть существующую заявку |
PROFILE_VERIFICATION_PERSON_NOT_SELF | 422 | Нет активной персоны Self (INV-VRF-3) | Вести в UC-PRF-001 |
PROFILE_VERIFICATION_INCOMPLETE | 422 | INV-VRF-2 | Чек-лист недостающего |
PROFILE_VERIFICATION_UNDERAGE | 422 | INV-VRF-4 | Пояснение «18+» |
PROFILE_VERIFICATION_NOT_EDITABLE | 409 | Правка/отзыв вне допустимого статуса (INV-VRF-7) | Перечитать статус |
PROFILE_VERIFICATION_ALREADY_TAKEN | 409 | Гонка ревьюеров (INV-VRF-6) | Обновить очередь |
PROFILE_VERIFICATION_NOT_REVIEWER | 403 | Решение не тем ревьюером (INV-VRF-8) | Показать владельца ревью |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | Не инвестор (INV-VRF-5) | Скрыть раздел |
PROFILE_MANAGED_EXTERNALLY | 403 | TalentSync (INV-PRF-2) | UC-PRF-005 |
404 NOT_FOUND | 404 | Чужая/несуществующая заявка | Обновить список |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
VerificationRequestCreatedEvent | Создание черновика | in-proc аудит |
VerificationRequestSubmittedEvent | Draft → Submitted | in-proc обработчик → Profile.VerificationStatus = Pending (INV-PRF-6) |
VerificationRequestDecidedEvent | Approved/Rejected | in-proc обработчик → Profile.VerificationStatus = Verified/Rejected (одно согласованное изменение) |
VerificationSnapshotAccessedEvent | Каждая расшифровка снапшота ревьюером (шаг 7) | in-proc аудит (обязательная запись) |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
VerificationRequestSubmittedKafkaEvent.v1 | pin.profile.verification-requests.v1 | Draft → Submitted (Id, OwnerUserId, SubmittedAt — без PII) | notifications, support (очередь ревью) |
VerificationCompletedKafkaEvent.v1 | pin.profile.verification-requests.v1 | Approved/Rejected (Id, OwnerUserId, Status, RejectionCode?, CompletedAt) | notifications (инвестору), crm, catalog (бейдж «верифицирован») |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | Submit фиксирует зашифрованный снапшот; последующие правки персоны НЕ меняют данные, которые видит ревьюер | тест: правка персоны после Submit |
| AC2 | На аккаунт — не более одной заявки в Draft/Submitted/InReview, в т.ч. при конкурентном создании | конкурентный тест unique-индекса |
| AC3 | Approve в одном согласованном изменении обновляет заявку, Profile.VerificationStatus = Verified и публикацию события | интеграционный тест с падением между шагами |
| AC4 | Reject требует code + reason; инвестор видит reason, но не DecisionComment | тест A1 |
| AC5 | Гонка ревьюеров: ровно один берёт заявку (оптимистическая блокировка по версии записи) | конкурентный тест INV-VRF-6 |
| AC6 | Каждый просмотр снапшота ревьюером порождает аудит-событие | тест шага 7 |
| AC7 | Kafka-payload обоих событий не содержит ФИО/дат/номеров документов | контракт-тест |
| AC8 | Заявки не удаляются: терминальные Approved/Rejected остаются в истории | проверка отсутствия delete-API и soft-delete |
| AC9 | Submit при < 18 лет / без паспортного документа / без сканов отклоняется до смены статуса | тесты INV-VRF-2/4 |
Обратные ссылки
Автоссылки: где используется этот документ.
- API (9): GET /api/v1/admin/profile/verification-queue — Очередь ревью KYC (listVerificationQueue), GET /api/v1/admin/profile/verification/{requestId}/snapshot — Аудируемый просмотр снапшота KYC (viewVerificationSnapshot), 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/{requestId}/revoke — Отзыв заявки KYC на доработку (revokeVerification), POST /api/v1/profile/verification/{requestId}/submit — Отправка заявки KYC на ревью (submitVerification), PUT /api/v1/profile/verification/{requestId} — Правка черновика заявки KYC (updateVerificationDraft)
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API методы (будущие):
../api/public/create-verification-request.md,../api/public/submit-verification.md,../api/public/revoke-verification.md,../api/admin/list-verification-queue.md,../api/admin/take-verification-in-review.md,../api/admin/approve-verification.md,../api/admin/reject-verification.md,../api/admin/view-verification-snapshot.md. - Domain entity: ../domain/verification-request.md
(машина состояний, INV-VRF-1..9,
VerificationSnapshotModel), ../domain/profile.md (денормализацияVerificationStatus, INV-PRF-6), ../domain/person.md, ../domain/person-document.md. - Контракты событий: ../events/verification-request-submitted.md, ../events/verification-completed.md.
- Потребители (будущие ТЗ):
docs/06-services/notifications/,docs/06-services/support/,docs/06-services/crm/,docs/06-services/catalog/. - State machine: диаграмма в ../domain/verification-request.md.
- Связанные UC: UC-PRF-001/002 (данные персоны и документы — предусловия), UC-PRF-004 (шаблон реквизитов как источник предзаполнения).