POST /api/v1/admin/profile/verification/{requestId}/approve — Одобрить заявку KYC (approveVerification)
Назначение
Ревьюер ПИН одобряет взятую в работу заявку на верификацию/квалификацию инвестора (KYC/AML):
терминальный переход InReview → Approved (INV-VRF-8 DecisionByReviewer,
verification-request.md). В том же согласованном изменении аккаунт
становится верифицированным — денормализованный Profile.VerificationStatus: Pending → Verified
(INV-PRF-6, profile.md), а факт решения транслируется наружу событием
VerificationCompletedKafkaEvent (топик pin.profile.verification-requests.v1): notifications уведомляет
инвестора, crm/catalog показывают бейдж «верифицирован». Расшифрованный снапшот на этой операции не
читается — решение принято ранее на аудируемом просмотре viewVerificationSnapshot. Операция за feature
flag profile.verification.enabled.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | profile |
| API area | admin (сотрудники ПИН, review-очередь KYC) |
| Feature group | verification |
| Статус | draft |
| Документ | docs/06-services/profile/api/admin/approve-verification.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Operator(Admin) — reviewer (сотрудник ПИН, Bearer JWT) |
| Auth policy / Permissions | authenticated; permission profile.verification.review (нет permission → 403) |
| Scope (RBAC) | платформенный (админ, вне tenant) |
| Record-level | только actor == VerificationRequestEntity.ReviewerUserId или супервизор заботы с тем же permission (INV-VRF-8); иначе PROFILE_VERIFICATION_NOT_REVIEWER |
| Tenant isolation | не применяется (ADR-0052: данные инвестора вне tenant; заявка не tenanted) |
| Audit | update: решение Approved, CompletedAt, DecisionComment; actor-stamping UpdatedByUserId = actor, Version++; доменное событие VerificationRequestDecidedEvent. KYC-записи не удаляются (аудит-история решений) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/admin/profile/verification/{requestId}/approve |
| Success status | 200 |
| Idempotency header | не применяется (терминальный переход; повтор на уже Approved → 409 PROFILE_VERIFICATION_INVALID_STATE) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ReviewerUserId | identity claims (sub) | Guid | Да | валидный JWT; permission profile.verification.review | 401 без токена; 403 без permission |
requestId | route | Guid | Да | NotEmpty; существующая заявка в InReview | чужая по ReviewerUserId → 403 |
DecisionComment | body | string? | Нет | ≤ 1024; внутренний комментарий ревьюера, инвестору не показывается; не цитировать значения документов | Internal: наружу и в Kafka не публикуется |
Модель ответа VerificationRequestModel
Admin-проекция заявки (общая для операций review-очереди: take/approve/reject). Дом полей —
verification-request.md; здесь только дельта без чувствительных
значений снапшота (расшифровка — отдельная аудируемая операция viewVerificationSnapshot).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
id | Guid | Да | VerificationRequestEntity.Id | — | Идентификатор заявки. |
ownerUserId | Guid | Да | VerificationRequestEntity.OwnerUserId | — | Верифицированная учётная запись (инвестор). |
personId | Guid | Да | VerificationRequestEntity.PersonId | — | Персона-предмет проверки (Relation = Self). |
requisiteTemplateId | Guid? | Нет | VerificationRequestEntity.RequisiteTemplateId | — | Шаблон предзаполнения; информационно. |
status | VerificationRequestStatus | Да | VerificationRequestEntity.Status | — | После операции — Approved (терминальный). |
submittedAt | DateTimeOffset? | Нет | VerificationRequestEntity.SubmittedAt | — | Момент отправки в очередь. |
reviewStartedAt | DateTimeOffset? | Нет | VerificationRequestEntity.ReviewStartedAt | — | Момент взятия в ревью. |
reviewerUserId | Guid? | Нет | VerificationRequestEntity.ReviewerUserId | — | Ревьюер, принявший решение. |
completedAt | DateTimeOffset? | Нет | VerificationRequestEntity.CompletedAt | — | Момент решения (= now). |
decisionComment | string? | Нет | VerificationRequestEntity.DecisionComment | admin-only | Внутренний комментарий; отдаётся только в admin-контур, инвестору — нет. |
scanCount | int | Да | VerificationRequestEntity.ScanList.Items.Count | — | Кол-во сканов заявки (метаданные). |
createdAt | DateTimeOffset | Да | VerificationRequestEntity.CreatedAt | — | Создание черновика. |
version | long | Да | VerificationRequestEntity.Version | — | Версия агрегата после мутации. |
Алгоритм
- Контекст и доступ. Операцию выполняет ревьюер ПИН (
ReviewerUserId=sub) с permissionprofile.verification.review; без permission — 403. Если feature flagprofile.verification.enabledвыключен →PROFILE_VERIFICATION_DISABLED(404). Tenant не применяется (ADR-0052). - Проверка входа и состояния.
requestIdне пустой,DecisionComment≤ 1024 — иначеValidationError. Заявка должна существовать — иначеPROFILE_VERIFICATION_NOT_FOUND(404). Заявка должна быть в статусеInReview— иначеPROFILE_VERIFICATION_INVALID_STATE(409:Submittedещё не взята,Approved/Rejectedуже терминальны). Решение принимает только назначенный ревьюер (ReviewerUserIdзаявки) или супервизор заботы с тем же permission — иначеPROFILE_VERIFICATION_NOT_REVIEWER(403, INV-VRF-8). - Что читаем. Заявку — как предмет решения; профиль-владелец (
OwnerUserId) — для денормализации статуса (владелец существует всегда: профиль создаётся раньше заявки). Снапшот (SnapshotCipher) не расшифровывается — PII на этой операции не читается (решение принято ранее наviewVerificationSnapshot). - Переход и денормализация. Заявка переходит
InReview → Approved(guard INV-VRF-8: актор — назначенный ревьюер/супервизор):Status = Approved,CompletedAt = now,DecisionComment= вход, актор фиксируется вUpdatedByUserId. В том же изменении профиль-владелец помечается верифицированным (INV-PRF-6):VerificationStatus: Pending → Verified,VerifiedAt = now,VerifiedRequestId= id заявки. - Согласованность. Заявка, профиль и публикация события фиксируются в одном согласованном изменении
(атомарно). Конкурентная правка (гонка ревьюеров или консюмера identity) отклоняется как
PROFILE_CONCURRENCY_CONFLICT(409). - События. Наружу публикуется
VerificationCompletedKafkaEvent(топикpin.profile.verification-requests.v1, keyOwnerUserId,AggregateType = "VerificationRequest"), payload{ Id, OwnerUserId, Status = Approved, RejectionCode = null, CompletedAt }— без PII (контракт — ../events/verification-completed.md). Потребители:notifications(уведомление инвестору),crm/catalog(бейдж «верифицирован»). - Результат. Admin-проекция заявки (
status = Approved,completedAt,reviewerUserId,decisionComment; снапшот не включён). HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | VerificationCompletedKafkaEvent → pin.profile.verification-requests.v1 (key OwnerUserId, Status = Approved) |
| External calls | none (снапшот не расшифровывается — PII не читается) |
| Cache / projections / search | Profile.VerificationStatus → Verified (+VerifiedAt, VerifiedRequestId) в той же транзакции (INV-PRF-6) |
| Notifications | опосредованно: notifications — консюмер verification-completed (уведомление инвестору «вы верифицированы») |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
— (authz) / PROFILE_VERIFICATION_NOT_REVIEWER | 403 | Нет permission profile.verification.review, либо actor не назначенный ревьюер/не супервизор (INV-VRF-8) | «Недостаточно прав для решения по заявке» | no retry |
ValidationError | 400 | Пустой requestId / DecisionComment > 1024 | RestApiValidationErrorResponse | no retry |
PROFILE_VERIFICATION_DISABLED | 404 | Feature flag profile.verification.enabled выключен | «Раздел верификации недоступен» | no retry |
PROFILE_VERIFICATION_NOT_FOUND | 404 | Заявка по requestId не найдена | «Заявка не найдена» | no retry |
PROFILE_VERIFICATION_INVALID_STATE | 409 | Status != InReview (не взята / уже терминальна) | «Заявка не в статусе рассмотрения» | no retry (перечитать очередь) |
PROFILE_CONCURRENCY_CONFLICT | 409 | Гонка версии агрегата (xmin) заявки/профиля | «Данные изменены, повторите» | retry (перечитка + повтор) |
Совместимость и наблюдаемость
- Расширения
VerificationRequestModel— additive (optional-поля без breaking change); удаление/переименование/смена типа — breaking (migration + rollout). Контракт Kafka-события — по ../events/verification-completed.md (ContractVersion, unknown-enum правила). - Logs:
requestId,ownerUserId,reviewerUserId,decision=Approved(безDecisionCommentи значений PII); metrics:profile_api_operations_total{operation="approveVerification",result},profile_verification_decisions_total{decision="approved"}, latency p99; traces — OTel. Dashboardprofile-overview; runbook — очередь ревью KYC (docs/06-services/profile/runbooks/, будущий путь по конвенции).
Acceptance Criteria
- Happy path. Given заявка
InReview, актор — назначенный ревьюер сprofile.verification.review; When approve; Then200,InReview → Approved(CompletedAt,DecisionComment), в том же измененииProfile.VerificationStatus: Pending → Verified(VerifiedAt,VerifiedRequestId, INV-PRF-6), опубликованVerificationCompletedKafkaEvent(Status = Approved, RejectionCode = null)без PII. - Не взята в ревью. Given заявка
Submitted(ещё не взята); When approve; Then409 PROFILE_VERIFICATION_INVALID_STATE; ни заявка, ни профиль не меняются, событие не публикуется. - Не тот ревьюер. Given заявка
InReview, но актор не назначенный ревьюер и не супервизор заботы; When approve; Then403 PROFILE_VERIFICATION_NOT_REVIEWER(INV-VRF-8). - Повторное одобрение. Given заявка уже
Approved; When повтор; Then409 PROFILE_VERIFICATION_INVALID_STATE; событие остаётся опубликованным ровно один раз (при первом решении). - Гонка ревьюеров. Given двое проходят проверку статуса одновременно; When оба фиксируют решение; Then один —
200, второй —409 PROFILE_CONCURRENCY_CONFLICT; событие ровно одно. - Флаг выключен. Given
profile.verification.enabled = off; When approve; Then404 PROFILE_VERIFICATION_DISABLED. - Кросс-сервисная согласованность. Given approve зафиксирован; When
crm/catalogещё не потребили событие; Then internalgetVerificationStatus(ownerUserId)уже отдаётVerifiedсинхронно (INV-PRF-6), а бейдж «верифицирован» вcrm/catalogпоявляется в течение секунд после потребленияVerificationCompletedKafkaEvent(окно eventual-consistency).
Обратные ссылки
Автоссылки: где используется этот документ.
- API (2): GET /api/v1/admin/profile/verification/{requestId}/snapshot — Аудируемый просмотр снапшота KYC (viewVerificationSnapshot), Profile API — реестр операций областей public / internal / admin
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: verification-request.md (машина состояний, INV-VRF-8
DecisionByReviewer,
CompletedAt/DecisionComment,RowVersion), profile.md (INV-PRF-6 VerificationDenorm,VerificationStatus/VerifiedAt/VerifiedRequestId). - Событие итога: ../events/verification-completed.md; permission
profile.verification.review— permission.md. - Соседние admin-операции очереди (будущие по конвенции):
list-verification-queue.md,take-verification-in-review.md,reject-verification.md,view-verification-snapshot.md; self-просмотр — get-my-verification.md. - Сценарий: UC-PRF-003-submit-and-approve-verification.md; ADR-0052, ADR-0056.