POST /api/v1/admin/profile/verification/{requestId}/reject — Отклонить заявку KYC (rejectVerification)
Назначение
Ревьюер ПИН отклоняет взятую в ревью заявку на верификацию/квалификацию инвестора (KYC/AML): переход
InReview → Rejected (терминальный) с машиночитаемым кодом (VerificationRejectionCode) и
человекочитаемым пояснением для инвестора (без цитирования значений документов). В том же согласованном
изменении денормализованный Profile.VerificationStatus переводится в Rejected (INV-PRF-6,
profile.md); наружу уходит VerificationCompletedKafkaEvent (топик
pin.profile.verification-requests.v1) — notifications уведомляет инвестора. После отказа инвестор может
создать новую заявку (INV-VRF-9 снят). Парная операция одобрения — approve-verification.md. Операция за
feature flag profile.verification.enabled.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | profile |
| API area | admin (сотрудники ПИН, review-очередь KYC) |
| Feature group | verification |
| Статус | draft |
| Документ | docs/06-services/profile/api/admin/reject-verification.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | Operator(Admin) — ревьюер ПИН (UserType = PinEmployee), Bearer JWT |
| Auth policy / Permissions | authenticated; permission profile.verification.review (permission.md) |
| Scope (RBAC) | платформенный (review вне tenant, ADR-0052) — доступ к чужим заявкам инвесторов по permission |
| Record-level | actor == VerificationRequestEntity.ReviewerUserId или супервизор заботы с тем же permission (INV-VRF-8); фиксируется в UpdatedByUserId |
| Tenant isolation | не применяется (ADR-0052: данные инвестора вне tenant) |
| Audit | update-sensitive: UpdatedByUserId = ReviewerUserId, VerificationRequestDecidedEvent; решение хранится бессрочно (KYC-аудит, без soft-delete) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/admin/profile/verification/{requestId}/reject |
| Success status | 200 |
| Idempotency header | не применяется (терминальный переход; повтор гасится статус-guard InReview → 409) |
| Correlation | OpenTelemetry trace (traceparent) |
Входные данные / параметры запроса (body RejectVerificationForm)
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
ReviewerUserId | identity claims (sub) | Guid | Да | валидный JWT; NotEmpty | 401 без токена |
requestId | route | Guid | Да | NotEmpty; существующая заявка (шаг 3) | несуществующая → 404 |
rejectionCode | body | VerificationRejectionCode | Да | enum: IllegibleScans / DataMismatch / DocumentExpired / SuspectedFraud / Other (verification-request.md) | — |
rejectionReason | body | string | Да | 1–1024, не пустое после trim; при rejectionCode = Other — развёрнутое (≥ 20 симв., INV-VRF-8) | видно инвестору; не цитировать значения документов |
decisionComment | body | string? | Нет | ≤ 1024 | внутренний комментарий ревьюера; инвестору не показывается |
Модель ответа VerificationRequestModel
Admin-проекция заявки (ревьюеру доступны внутренние поля). Дом полей —
verification-request.md; здесь только проекция без расшифрованного
снапшота (SnapshotCipher наружу не отдаётся — просмотр снапшота это отдельная аудируемая операция
view-verification-snapshot.md, VerificationSnapshotAccessedEvent).
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
id | Guid | Да | VerificationRequestEntity.Id | — | Идентификатор заявки. |
ownerUserId | Guid | Да | VerificationRequestEntity.OwnerUserId | — | Отклонённая учётная запись инвестора. |
personId | Guid | Да | VerificationRequestEntity.PersonId | — | Персона-предмет проверки (Relation = Self). |
requisiteTemplateId | Guid? | Нет | VerificationRequestEntity.RequisiteTemplateId | — | Шаблон предзаполнения; информационно. |
status | VerificationRequestStatus | Да | VerificationRequestEntity.Status | — | После операции — Rejected (терминальный). |
submittedAt | DateTimeOffset? | Нет | VerificationRequestEntity.SubmittedAt | — | Момент отправки в очередь. |
reviewStartedAt | DateTimeOffset? | Нет | VerificationRequestEntity.ReviewStartedAt | — | Момент взятия в ревью. |
reviewerUserId | Guid? | Нет | VerificationRequestEntity.ReviewerUserId | — | Ревьюер, принявший решение. |
completedAt | DateTimeOffset? | Нет | VerificationRequestEntity.CompletedAt | — | Момент решения (= now). |
rejectionCode | VerificationRejectionCode? | Нет | VerificationRequestEntity.RejectionCode | — | Машиночитаемая причина отказа. |
rejectionReason | string? | Нет | VerificationRequestEntity.RejectionReason | — | Человекочитаемое пояснение (эхо входа). |
decisionComment | string? | Нет | VerificationRequestEntity.DecisionComment | — | Внутренний комментарий (только admin-проекция). |
scanCount | int | Да | VerificationRequestEntity.ScanList.Items.Count | — | Кол-во сканов заявки (метаданные). |
createdAt | DateTimeOffset | Да | VerificationRequestEntity.CreatedAt | — | Создание черновика. |
version | long | Да | VerificationRequestEntity.Version | — | Версия агрегата после решения. |
Алгоритм
- Контекст и доступ. Операцию выполняет ревьюер ПИН (
ReviewerUserId=sub) с permissionprofile.verification.review. Если feature flagprofile.verification.enabledвыключен →PROFILE_VERIFICATION_DISABLED(404). Tenant не применяется (ADR-0052). - Проверка входа.
requestIdне пустой;rejectionCode— валидный enum (IllegibleScans/DataMismatch/DocumentExpired/SuspectedFraud/Other);rejectionReasonне пустой, 1–1024 после trim; приrejectionCode = Other— развёрнутое пояснение (≥ 20 символов, INV-VRF-8);decisionComment≤ 1024 — иначеValidationError. - Проверка состояния и прав. Заявка должна существовать — иначе
PROFILE_VERIFICATION_NOT_FOUND(404). Заявка должна быть в статусеInReview— иначеPROFILE_VERIFICATION_INVALID_STATE(409; ужеApproved/Rejectedили не взята в ревью). Решение принимает только назначенный ревьюер (ReviewerUserIdзаявки) или супервизор заботы с тем же permission — иначеPROFILE_VERIFICATION_NOT_REVIEWER(403, INV-VRF-8). Снапшот не расшифровывается — отказ не требует чтения PII. - Переход и денормализация. Заявка переходит
InReview → Rejected:Status = Rejected,CompletedAt = now,RejectionCode/RejectionReason/DecisionComment= вход, актор фиксируется вUpdatedByUserId. В том же изменении профиль-владелец:VerificationStatus: Pending → Rejected(INV-PRF-6;Verified/VerifiedRequestIdне трогаются). - Согласованность. Заявка, профиль и публикация события фиксируются атомарно. Гонка двух ревьюеров
(INV-VRF-6) отклоняется у проигравшего как
PROFILE_CONCURRENCY_CONFLICT(409). - События. Наружу —
VerificationCompletedKafkaEvent(топикpin.profile.verification-requests.v1, keyOwnerUserId), payload{ Id, OwnerUserId, Status = Rejected, RejectionCode, CompletedAt }— без PII (RejectionReason/DecisionCommentне публикуются). Контракт — ../events/verification-completed.md; потребитель —notifications. - Результат. Admin-проекция заявки (таблица выше; снапшот не расшифровывается). HTTP 200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | VerificationCompletedKafkaEvent → pin.profile.verification-requests.v1 (key = OwnerUserId, Status = Rejected) |
| External calls | none (снапшот не расшифровывается — PII не читается) |
| Cache / projections / search | денормализация Profile.VerificationStatus = Rejected (та же транзакция) |
| Notifications | опосредованно: notifications — консюмер verification-completed (уведомление инвестору: «заявка отклонена, подробности в ЛК»; RejectionReason в уведомление не попадает) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | Отсутствует rejectionCode/rejectionReason; Other без развёрнутого reason (INV-VRF-8) | RestApiValidationErrorResponse | no retry |
PROFILE_VERIFICATION_NOT_REVIEWER | 403 | Актор не ReviewerUserId и не супервизор (INV-VRF-8) | «Решение принимает взявший заявку ревьюер» | 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 | Статус заявки не InReview (не взята / уже решена) | «Заявка не находится на рассмотрении» | no retry (перечитать заявку) |
PROFILE_CONCURRENCY_CONFLICT | 409 | Гонка ревьюеров: RowVersion (xmin) изменился (INV-VRF-6) | «Заявку уже обновили, обновите данные» | retry (перечитка) |
Совместимость и наблюдаемость
- Новые optional-поля запроса/ответа — additive, без breaking change; удаление/переименование/смена типа —
breaking (migration + rollout). Новый элемент
VerificationRejectionCode— additive (дом verification-request.md); старый клиент трактует unknown-код консервативно. - Logs:
requestId,ReviewerUserId,OwnerUserId,rejectionCode, latency (безrejectionReasonи значений PII); metrics:profile_api_operations_total{operation="rejectVerification",result},profile_verification_decisions_total{decision="rejected",code}; traces — OTel. Dashboardprofile-overview; alert — всплеск отказов/конфликтов ревьюеров.
Acceptance Criteria
- Happy path. Given заявка
InReview, назначенный ревьюер, валидныеrejectionCode/rejectionReason; When reject; Then200,InReview → Rejected(CompletedAt,RejectionCode,RejectionReason,DecisionComment),Profile.VerificationStatus: Pending → Rejected(INV-PRF-6), опубликованVerificationCompletedKafkaEvent(Status = Rejected, RejectionCode)без PII. - Повторная заявка после отказа. Given заявка
Rejected; When инвестор создаёт новую заявку; Then это разрешено (INV-VRF-9 снят). Otherбез пояснения. GivenrejectionCode = Other,rejectionReasonкороче 20 символов; When reject; Then400 ValidationError(INV-VRF-8).- Пустой код/причина. Given отсутствует
rejectionCodeили пустой после trimrejectionReason; When reject; Then400 ValidationError. - Не в ревью. Given заявка не
InReview(не взята / уже терминальна); When reject; Then409 PROFILE_VERIFICATION_INVALID_STATE. - Не тот ревьюер. Given актор не
ReviewerUserIdи не супервизор; When reject; Then403 PROFILE_VERIFICATION_NOT_REVIEWER. - Гонка ревьюеров. Given двое решают одну заявку; When оба фиксируют; Then победитель —
200, проигравший —409 PROFILE_CONCURRENCY_CONFLICT(INV-VRF-6). - PII не утекает. Given отказ опубликован; When
notificationsуведомляет инвестора; ThenRejectionReason/DecisionCommentв Kafka и в текст уведомления не попадают; развёрнутую причину инвестор видит только в self-view ЛК.
Обратные ссылки
Автоссылки: где используется этот документ.
- 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-6..9,
RejectionCode/RejectionReason), profile.md (денормализацияVerificationStatus, INV-PRF-6). - Событие: ../events/verification-completed.md; permission
profile.verification.review— permission.md. - Парная и соседние операции:
approve-verification.md,take-verification-in-review.md,view-verification-snapshot.md; self-view инвестора — get-my-verification.md. - Сценарий: UC-PRF-003-submit-and-approve-verification.md; SDK — docs/11-backend-sdk/.