POST /api/v1/profile/verification/{requestId}/submit — Отправка заявки KYC на ревью (submitVerification)
Назначение
Зафиксировать иммутабельный зашифрованный снапшот данных персоны и отправить заявку на простую
верификацию инвестора (KYC/AML) в очередь ревью ПИН: переход Draft → Submitted. Метод собирает
VerificationSnapshotModel из персоны Relation = Self и её документов (расшифровка → сборка →
сериализация → шифрование одним KeyVersion), чтобы решение ревьюера навсегда опиралось на данные на
момент Submit, даже если персона потом изменится (INV-VRF-7). В той же транзакции денормализуется
Profile.VerificationStatus → Pending (INV-PRF-6) и публикуется событие очереди ревью. Дом полей —
verification-request.md. Вызывает инвестор из ЛК.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | profile |
| API area | public |
| Feature group | verification |
| Статус | draft |
| Документ | docs/06-services/profile/api/public/submit-verification.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Bearer JWT), UserType = Investor (INV-VRF-5) |
| Auth policy / Permissions | authenticated; permission profile.verification.submit |
| Scope (RBAC) | self |
| Record-level | VerificationRequestEntity.OwnerUserId = UserId из sub (из тела/маршрута не принимается); чужой requestId → 404 |
| Tenant isolation | не применяется (ADR-0052: инвестор вне tenant) |
| Audit | update: UpdatedByUserId = UserId, Version++; событие VerificationRequestSubmittedEvent; снапшот — только шифротекст (в логи/события не попадает) |
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/v1/profile/verification/{requestId}/submit |
| Success status | 200 |
| Idempotency header | Idempotency-Key (опционально); повтор дополнительно гасится статусной проверкой (не Draft → PROFILE_VERIFICATION_NOT_EDITABLE) |
| Correlation | сквозной trace через заголовок traceparent |
Входные данные / параметры запроса
Тело запроса отсутствует (данные заявки уже накоплены в Draft через createVerificationRequest /
updateVerificationDraft). Снапшот собирается сервером из персоны и её документов.
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
UserId | identity claims (sub) | Guid | Да | валидный JWT | 401 без токена |
requestId | route | Guid | Да | NotEmpty; заявка владельца в статусе Draft | чужая → 404 |
Модель ответа VerificationRequestModel
Безопасная read-проекция агрегата (verification-request.md) — без PII (снапшот наружу не отдаётся; расшифровка снапшота — только ревьюером через admin API). Дом модели — get-my-verification.md; приведён здесь для самодостаточности.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | VerificationRequestEntity.Id | — | Идентификатор заявки. |
PersonId | Guid | Да | entity.PersonId | — | Персона-предмет проверки (Self). |
RequisiteTemplateId | Guid? | Нет | entity.RequisiteTemplateId | — | Шаблон-источник предзаполнения (если был). |
Status | VerificationRequestStatus (string) | Да | entity.Status | — | После операции — Submitted. |
HasSnapshot | bool | Да | entity.SnapshotCipher != null | — | Снапшот зафиксирован (после Submit — true). |
SnapshotContractVersion | int? | Нет | entity.SnapshotContractVersion | — | Версия модели снапшота (1). |
ScanCount | int | Да | entity.ScanList.Items.Count | — | Число сканов, приложенных к заявке. |
SubmittedAt | DateTimeOffset? | Да (после Submit) | entity.SubmittedAt | — | Момент постановки в очередь ревью. |
Version | long | Да | entity.Version | — | Версия агрегата (optimistic concurrency). |
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (
UserIdиз токена); tenant не применяется. Повторный запрос с тем жеIdempotency-Keyвозвращает ранее сохранённый результат без повторной сборки снапшота. - Проверка предусловий. Заявка
requestIdсуществует и принадлежит владельцу; иначеPROFILE_VERIFICATION_NOT_FOUND(record-level, чужая не раскрывается). Статус заявки —Draft; иначеPROFILE_VERIFICATION_NOT_EDITABLE(INV-VRF-7). Профиль владельца активен (Status = Active, иначеPROFILE_USER_BLOCKED, INV-PRF-3), в режиме самоуправления (ContactsManagedBy = SelfService, иначеPROFILE_MANAGED_EXTERNALLY, INV-PRF-2), тип пользователя —Investor(иначеPROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE, INV-VRF-5). - Что читаем. Персона
Self(request.PersonId) с её документами — нужна для сборки снапшота; проверки полноты выполняются в памяти. - Бизнес-правила и сборка снапшота.
- расшифровка персоны: ФИО и
BirthDateрасшифровываются ключом версии записи персоны; - полнота (INV-VRF-2): ФИО и
BirthDateзаполнены; среди активных документов есть паспортный (Type ∈ {RfPassport, RbPassport, KzPassport, CisPassport, ForeignPassport}); суммарно ≥ 1 скана (сканы документов снапшота + сканы заявки) — иначеPROFILE_VERIFICATION_INCOMPLETE; - возраст (INV-VRF-4): полных лет по
BirthDateна текущий момент ≥ 18 — иначеPROFILE_VERIFICATION_UNDERAGE; - сборка
VerificationSnapshotModel(ContractVersion = 1):PersonId,PersonVersion(сверка «данные не менялись»),FirstName/LastName/MiddleName,BirthDate,CitizenshipCountryId;IdentityDocument= снапшот паспортного документа (DocumentId,Type,CountryId,CountryIsoCode2— юридический след,PayloadContractVersion, расшифрованныйPayload, сканы; при нескольких — самый свежий поIssueDate);AdditionalDocuments— прочие активные документы (≤ 10, каждый как снапшот); - шифрование: снапшот сериализуется и шифруется одним текущим ключом;
SnapshotContractVersion = 1,KeyVersionфиксируется (INV-VRF-7, снапшот иммутабелен далее); недоступность ключей — fail-closedPROFILE_CRYPTO_UNAVAILABLE; - переход:
Draft → Submitted;SubmittedAt = now;UpdatedByUserId = UserId;Version++; фиксируется фактVerificationRequestSubmittedEvent. Денормализация профиля (INV-PRF-6):Profile.VerificationStatusNotVerified|Rejected → Pending.
- расшифровка персоны: ФИО и
- Согласованность. В одном согласованном изменении сохраняются заявка (
Submitted, снапшот), профиль (Pending), интеграционное событие и запись идемпотентности; конкурентныйSubmit/Revokeразрешается конфликтом версий →PROFILE_CONCURRENCY_CONFLICT. - Событие. Публикуется
VerificationRequestSubmittedKafkaEvent(ContractVersion = 1, topicpin.profile.verification-requests.v1, key =OwnerUserId; payload без PII:Id,OwnerUserId,SubmittedAt) — контракт verification-request-submitted.md. - Результат. Модель
VerificationRequestModel(проекция агрегата, без снапшота); HTTP200.
Диаграмма
Побочные эффекты
| Тип | Детали |
|---|---|
| Интеграционные события | VerificationRequestSubmittedKafkaEvent / pin.profile.verification-requests.v1 / key = OwnerUserId (без PII) |
| Внешние вызовы | провайдер ключей шифрования: расшифровка персоны/документов + шифрование снапшота; недоступность → PROFILE_CRYPTO_UNAVAILABLE (fail-closed) |
| Проекции | денормализация Profile.VerificationStatus = Pending (та же транзакция) |
| Уведомления | опосредованно через событие: notifications (подтверждение инвестору, сигнал очереди ревью), support (тикет ревью) |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
| — (auth) | 401 | Нет/просрочен JWT | без тела | re-login |
ValidationError | 400 | requestId пустой | перечень невалидных полей | no retry |
PROFILE_VERIFICATION_NOT_FOUND | 404 | Заявка не найдена / чужая (record-level) | «Заявка не найдена» | no retry |
PROFILE_VERIFICATION_NOT_EDITABLE | 409 | Status != Draft (INV-VRF-7) | «Заявка уже отправлена» | перечитать статус |
PROFILE_VERIFICATION_INCOMPLETE | 400 | Нет ФИО/даты рождения, активного паспортного документа или ни одного скана (INV-VRF-2) | «Заполните данные и приложите сканы» | no retry |
PROFILE_VERIFICATION_UNDERAGE | 400 | Возраст по BirthDate < 18 на момент Submit (INV-VRF-4) | «Верификация доступна с 18 лет» | no retry |
PROFILE_USER_BLOCKED | 400 | Profile.Status != Active (INV-PRF-3) | «Учётная запись заблокирована» | no retry |
PROFILE_MANAGED_EXTERNALLY | 403 | ContactsManagedBy = TalentSync (INV-PRF-2) | «Данные управляются внешней системой» | no retry |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | UserType != Investor (INV-VRF-5) | «Доступно только инвесторам» | no retry |
PROFILE_CONCURRENCY_CONFLICT | 409 | Гонка Submit/Revoke (конфликт версий) | «Повторите попытку» | retry (перечитка) |
PROFILE_CRYPTO_UNAVAILABLE | 503 | Ключи шифрования недоступны | «Сервис временно недоступен» | retry |
Совместимость и наблюдаемость
VerificationSnapshotModel/VerificationRequestSubmittedKafkaEvent— versioned (ContractVersion = 1); изменения снапшота — additive (новые nullable-поля с bumpSnapshotContractVersion); breaking — новый топик.v2(дом контракта — событие). Значения PII не попадают в ответ, логи, метрики и шину.- Logs:
UserId,requestId,PersonId, переходDraft→Submitted(без ФИО/дат/номеров документов); metrics:profile_api_operations_total{operation="submitVerification",result},profile_verification_submitted_total,profile_crypto_errors_total; traces — OTel; dashboardprofile-overview; alert «заявки вSubmittedстарше 48 ч».
Acceptance criteria
- Дано черновик владельца в
Draft, у персоныSelfзаполнены ФИО и дата рождения (возраст ≥ 18), есть активный паспортный документ и суммарно ≥ 1 скана; когда submit; тогда собирается и шифруется однимKeyVersionиммутабельный снапшот (SnapshotContractVersion = 1), статус →Submitted,SubmittedAt = now,Profile.VerificationStatus→Pending, публикуетсяVerificationRequestSubmittedKafkaEvent(без PII, key =OwnerUserId), HTTP200сHasSnapshot = true. - Дано нет паспортного документа / нет ФИО или даты рождения / ни одного скана (INV-VRF-2); когда submit; тогда
PROFILE_VERIFICATION_INCOMPLETE(400): статус остаётсяDraft, снапшот не создан, событий нет. - Дано возраст персоны по
BirthDate< 18 на момент submit (INV-VRF-4); когда submit; тогдаPROFILE_VERIFICATION_UNDERAGE(400). - Дано заявка не в
Draft(ужеSubmitted/InReview/терминальная, INV-VRF-7); когда submit; тогдаPROFILE_VERIFICATION_NOT_EDITABLE(409). - Дано провайдер ключей шифрования недоступен; когда submit; тогда fail-closed
PROFILE_CRYPTO_UNAVAILABLE(503): заявка остаётсяDraft, событий нет (retry позже). - Дано тот же
Idempotency-Keyповторяется после успеха; когда submit; тогда возвращается ранее сохранённый результат без повторной сборки снапшота и без повторной публикации события. - Дано конкурентные submit/revoke на ту же заявку; когда оба коммитятся; тогда проигравший получает
PROFILE_CONCURRENCY_CONFLICT(409). - Дано заявка отправлена; когда проходит окно eventual-consistency; тогда асинхронно по одному событию (строгий порядок per-user):
notificationsшлёт подтверждение инвестору и сигнал в очередь ревью,supportзаводит тикет ревью — до этого инвестор видит заявку вSubmittedсо снапшотом.
Обратные ссылки
Автоссылки: где используется этот документ.
- API (5): GET /api/v1/profile/verification — Моя заявка на верификацию (getMyVerification), POST /api/v1/profile/verification — Создание черновика заявки KYC (createVerificationRequest), POST /api/v1/profile/verification/{requestId}/revoke — Отзыв заявки KYC на доработку (revokeVerification), Profile API — реестр операций областей public / internal / admin, PUT /api/v1/profile/verification/{requestId} — Правка черновика заявки KYC (updateVerificationDraft)
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущность (дом полей, INV-VRF-1..9,
VerificationSnapshotModel, stateDiagram): verification-request.md; источники снапшота — person.md, person-document.md; денормализация статуса — profile.md (ProfileVerificationStatus, INV-PRF-6). - Событие: verification-request-submitted.md; сценарий: UC-PRF-003 (шаг 5).