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

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 areaadmin (сотрудники ПИН, review-очередь KYC)
Feature groupverification
Статусdraft
Документdocs/06-services/profile/api/admin/reject-verification.md

Актор и доступ

ПроверкаЗначение
Actor typeOperator(Admin) — ревьюер ПИН (UserType = PinEmployee), Bearer JWT
Auth policy / Permissionsauthenticated; permission profile.verification.review (permission.md)
Scope (RBAC)платформенный (review вне tenant, ADR-0052) — доступ к чужим заявкам инвесторов по permission
Record-levelactor == VerificationRequestEntity.ReviewerUserId или супервизор заботы с тем же permission (INV-VRF-8); фиксируется в UpdatedByUserId
Tenant isolationне применяется (ADR-0052: данные инвестора вне tenant)
Auditupdate-sensitive: UpdatedByUserId = ReviewerUserId, VerificationRequestDecidedEvent; решение хранится бессрочно (KYC-аудит, без soft-delete)

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/v1/admin/profile/verification/{requestId}/reject
Success status200
Idempotency headerне применяется (терминальный переход; повтор гасится статус-guard InReview → 409)
CorrelationOpenTelemetry trace (traceparent)

Входные данные / параметры запроса (body RejectVerificationForm)

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
ReviewerUserIdidentity claims (sub)GuidДавалидный JWT; NotEmpty401 без токена
requestIdrouteGuidДаNotEmpty; существующая заявка (шаг 3)несуществующая → 404
rejectionCodebodyVerificationRejectionCodeДаenum: IllegibleScans / DataMismatch / DocumentExpired / SuspectedFraud / Other (verification-request.md)
rejectionReasonbodystringДа1–1024, не пустое после trim; при rejectionCode = Other — развёрнутое (≥ 20 симв., INV-VRF-8)видно инвестору; не цитировать значения документов
decisionCommentbodystring?Нет≤ 1024внутренний комментарий ревьюера; инвестору не показывается

Модель ответа VerificationRequestModel

Admin-проекция заявки (ревьюеру доступны внутренние поля). Дом полей — verification-request.md; здесь только проекция без расшифрованного снапшота (SnapshotCipher наружу не отдаётся — просмотр снапшота это отдельная аудируемая операция view-verification-snapshot.md, VerificationSnapshotAccessedEvent).

ПолеТипОбязательностьИсточникMaskingОписание
idGuidДаVerificationRequestEntity.IdИдентификатор заявки.
ownerUserIdGuidДаVerificationRequestEntity.OwnerUserIdОтклонённая учётная запись инвестора.
personIdGuidДаVerificationRequestEntity.PersonIdПерсона-предмет проверки (Relation = Self).
requisiteTemplateIdGuid?НетVerificationRequestEntity.RequisiteTemplateIdШаблон предзаполнения; информационно.
statusVerificationRequestStatusДаVerificationRequestEntity.StatusПосле операции — Rejected (терминальный).
submittedAtDateTimeOffset?НетVerificationRequestEntity.SubmittedAtМомент отправки в очередь.
reviewStartedAtDateTimeOffset?НетVerificationRequestEntity.ReviewStartedAtМомент взятия в ревью.
reviewerUserIdGuid?НетVerificationRequestEntity.ReviewerUserIdРевьюер, принявший решение.
completedAtDateTimeOffset?НетVerificationRequestEntity.CompletedAtМомент решения (= now).
rejectionCodeVerificationRejectionCode?НетVerificationRequestEntity.RejectionCodeМашиночитаемая причина отказа.
rejectionReasonstring?НетVerificationRequestEntity.RejectionReasonЧеловекочитаемое пояснение (эхо входа).
decisionCommentstring?НетVerificationRequestEntity.DecisionCommentВнутренний комментарий (только admin-проекция).
scanCountintДаVerificationRequestEntity.ScanList.Items.CountКол-во сканов заявки (метаданные).
createdAtDateTimeOffsetДаVerificationRequestEntity.CreatedAtСоздание черновика.
versionlongДаVerificationRequestEntity.VersionВерсия агрегата после решения.

Алгоритм

  1. Контекст и доступ. Операцию выполняет ревьюер ПИН (ReviewerUserId = sub) с permission profile.verification.review. Если feature flag profile.verification.enabled выключен → PROFILE_VERIFICATION_DISABLED (404). Tenant не применяется (ADR-0052).
  2. Проверка входа. requestId не пустой; rejectionCode — валидный enum (IllegibleScans/DataMismatch/DocumentExpired/SuspectedFraud/Other); rejectionReason не пустой, 1–1024 после trim; при rejectionCode = Other — развёрнутое пояснение (≥ 20 символов, INV-VRF-8); decisionComment ≤ 1024 — иначе ValidationError.
  3. Проверка состояния и прав. Заявка должна существовать — иначе PROFILE_VERIFICATION_NOT_FOUND (404). Заявка должна быть в статусе InReview — иначе PROFILE_VERIFICATION_INVALID_STATE (409; уже Approved/Rejected или не взята в ревью). Решение принимает только назначенный ревьюер (ReviewerUserId заявки) или супервизор заботы с тем же permission — иначе PROFILE_VERIFICATION_NOT_REVIEWER (403, INV-VRF-8). Снапшот не расшифровывается — отказ не требует чтения PII.
  4. Переход и денормализация. Заявка переходит InReview → Rejected: Status = Rejected, CompletedAt = now, RejectionCode/RejectionReason/DecisionComment = вход, актор фиксируется в UpdatedByUserId. В том же изменении профиль-владелец: VerificationStatus: Pending → Rejected (INV-PRF-6; Verified/VerifiedRequestId не трогаются).
  5. Согласованность. Заявка, профиль и публикация события фиксируются атомарно. Гонка двух ревьюеров (INV-VRF-6) отклоняется у проигравшего как PROFILE_CONCURRENCY_CONFLICT (409).
  6. События. Наружу — VerificationCompletedKafkaEvent (топик pin.profile.verification-requests.v1, key OwnerUserId), payload { Id, OwnerUserId, Status = Rejected, RejectionCode, CompletedAt }без PII (RejectionReason/DecisionComment не публикуются). Контракт — ../events/verification-completed.md; потребитель — notifications.
  7. Результат. Admin-проекция заявки (таблица выше; снапшот не расшифровывается). HTTP 200.

Диаграмма

Побочные эффекты

ТипДетали
Integration eventsVerificationCompletedKafkaEventpin.profile.verification-requests.v1 (key = OwnerUserId, Status = Rejected)
External callsnone (снапшот не расшифровывается — PII не читается)
Cache / projections / searchденормализация Profile.VerificationStatus = Rejected (та же транзакция)
Notificationsопосредованно: notifications — консюмер verification-completed (уведомление инвестору: «заявка отклонена, подробности в ЛК»; RejectionReason в уведомление не попадает)

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
— (auth)401Нет/просрочен JWTбез телаre-login
ValidationError400Отсутствует rejectionCode/rejectionReason; Other без развёрнутого reason (INV-VRF-8)RestApiValidationErrorResponseno retry
PROFILE_VERIFICATION_NOT_REVIEWER403Актор не ReviewerUserId и не супервизор (INV-VRF-8)«Решение принимает взявший заявку ревьюер»no retry
PROFILE_VERIFICATION_DISABLED404Feature flag profile.verification.enabled выключен«Раздел верификации недоступен»no retry
PROFILE_VERIFICATION_NOT_FOUND404Заявка по requestId не найдена«Заявка не найдена»no retry
PROFILE_VERIFICATION_INVALID_STATE409Статус заявки не InReview (не взята / уже решена)«Заявка не находится на рассмотрении»no retry (перечитать заявку)
PROFILE_CONCURRENCY_CONFLICT409Гонка ревьюеров: 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. Dashboard profile-overview; alert — всплеск отказов/конфликтов ревьюеров.

Acceptance Criteria

  • Happy path. Given заявка InReview, назначенный ревьюер, валидные rejectionCode/rejectionReason; When reject; Then 200, 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 без пояснения. Given rejectionCode = Other, rejectionReason короче 20 символов; When reject; Then 400 ValidationError (INV-VRF-8).
  • Пустой код/причина. Given отсутствует rejectionCode или пустой после trim rejectionReason; When reject; Then 400 ValidationError.
  • Не в ревью. Given заявка не InReview (не взята / уже терминальна); When reject; Then 409 PROFILE_VERIFICATION_INVALID_STATE.
  • Не тот ревьюер. Given актор не ReviewerUserId и не супервизор; When reject; Then 403 PROFILE_VERIFICATION_NOT_REVIEWER.
  • Гонка ревьюеров. Given двое решают одну заявку; When оба фиксируют; Then победитель — 200, проигравший — 409 PROFILE_CONCURRENCY_CONFLICT (INV-VRF-6).
  • PII не утекает. Given отказ опубликован; When notifications уведомляет инвестора; Then RejectionReason/DecisionComment в Kafka и в текст уведомления не попадают; развёрнутую причину инвестор видит только в self-view ЛК.

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

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

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