UC-PRF-002. Добавление персоны «Супруг(а)» с паспортом и сканами
Назначение
Добавление второго набора персональных данных на аккаунт: один аккаунт может принадлежать супругам, а
сделку оформлять на супруга или детей. Инвестор создаёт персону Relation = Spouse, добавляет её документ
(паспорт РФ/РБ/КЗ/СНГ/иностранный, ИНН, кастомный — typed payload, шифруется целиком в PayloadCipher) и
прикрепляет сканы через сервис storage. Связан с контекстом profile.
Metadata
| Поле | Значение |
|---|---|
| ID | UC-PRF-002 |
| Контекст | profile (см. docs/06-services/profile/) |
| Тип | основной |
| Статус | draft |
Актор
| Поле | Значение |
|---|---|
| Роль | Инвестор (только он управляет персонами/документами — INV-PRS-2) |
| Permissions / scope | profile.documents.manage-self (self-scope) |
| Tenant | не tenanted (ADR-0052); record-level по OwnerUserId = UserId актора |
| Record-level | только собственные персоны и их документы (OwnerUserId) |
Предусловие
- Выполнен UC-PRF-001: профиль существует,
UserType = Investor,Status = Active,ContactsManagedBy = SelfService. - Активных персон на аккаунте < 20 (INV-PRS-7); у целевой персоны активных документов < 20 (INV-DOC-8).
Триггер
| Поле | Значение |
|---|---|
| Источник | UI button |
| Триггерящий элемент | /profile/personal → «Добавить персону»; карточка персоны → «Добавить документ»; форма документа → «Прикрепить скан» |
Основной Поток
- Инвестор нажимает «Добавить персону», заполняет форму (связь = «Супруг(а)», ФИО, дата рождения,
гражданство) и сохраняет. Фронт вызывает
createPerson(POST /profile/persons) сCreatePersonForm { relation (Spouse|Child|Parent|Other), firstName (1..128), lastName (1..128), middleName? (1..128), birthDate? (ISO), citizenshipCountryId? (short — справочник стран handbook), gender? }. (relation = Selfв этой форме допустим только в сценарии миграции UC-PRF-001 A2.) - Создание персоны.
- Проверить вход до шифрования: длины,
gender/relationиз enum, дата в прошлом →ValidationError;citizenshipCountryId(если задан) резолвится через internal resolve справочникаhandbook— неизвестная/архивная страна →PROFILE_COUNTRY_UNKNOWN(422). - Проверить доступ по профилю: режим самообслуживания
ContactsManagedBy = SelfService(INV-PRF-2/3, иначеPROFILE_MANAGED_EXTERNALLY); тип пользователя — инвестор (INV-PRS-2, иначеPROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE). - Проверить лимит: активных персон владельца < 20 (INV-PRS-7, иначе
PROFILE_PERSONS_LIMIT_EXCEEDED). - Создать
PersonEntity(Relation = Spouse,OwnerUserId = UserId): ФИО и дата рождения шифруются актуальным ключом (Name.*Cipher,BirthDateCipher,KeyVersion= текущий, INV-PRS-8);CitizenshipCountryId/Gender— открыто;Status = Active;DocumentsCount = 0;CreatedByUserId = UserId. - Публикуются
PersonCreatedEvent(домен) иPersonUpsertedKafkaEvent(ContractVersion = 1, топикpin.profile.persons.v1, key =Id; payload без PII). ВозвращаетсяPersonResult(id + расшифрованный self-view); фронт открывает карточку персоны.
- Проверить вход до шифрования: длины,
- Инвестор нажимает «Добавить документ», выбирает тип «Паспорт РФ» и заполняет typed-форму.
Фронт вызывает
addPersonDocument(POST /profile/persons/{personId}/documents) сAddPersonDocumentForm { type (PersonDocumentType), countryId (short — справочник стран handbook), title? (1..256 — обязателен для Custom, INV-DOC-3), payload (typed-модель по type: для RfPassport — RfPassportModel { series ^\d{4}$, number ^\d{6}$, issuedBy 1..512, departmentCode ^\d{3}-\d{3}$, issueDate ≤ today, birthPlace? ≤256, registrationAddress? ≤512 }) }— все typed-модели по типам: дом ../domain/person-document.md. - Добавление документа.
- Проверить typed-модель payload до шифрования (INV-DOC-1) →
ValidationError. - Резолвить
countryIdчерез internal resolve справочникаhandbook(INV-DOC-9): страна не найдена или архивна →PROFILE_COUNTRY_UNKNOWN(422);IsoCode2резолвленной страны запоминается для денормализацииCountryIsoCode2. - Проверить согласованность страны (INV-DOC-2) по
IsoCode2резолвленной страны:RfPassport→RU,RbPassport→BY,KzPassport→KZ,Inn→RU— фиксированы;CisPassport— только страны СНГ (AM/AZ/UZ/KG/TJ/MD/TM);countryIdформы =CountryIdpayload; нарушение →PROFILE_DOCUMENT_COUNTRY_MISMATCH. - Проверить доступ по профилю (INV-PRF-2/3, INV-PRS-2); найти персону по
personIdв пределах владельца (record-level),Status = Active. - Проверить лимит: активных документов персоны < 20 (INV-DOC-8, иначе
PROFILE_DOCUMENTS_LIMIT_EXCEEDED). - Проверить дубль:
NumberHash = HMACSHA256(hmacKey, normalize(series+number))(upper-case, без пробелов; для Custom без номера — поTitle); совпадение с активным документом того жеType→PROFILE_DOCUMENT_ALREADY_EXISTS; финальный арбитр гонки — уникальный индексux_person_documents_dedupe(person_id, type, number_hash) (INV-DOC-4). - Добавить документ: typed payload сериализуется и целиком шифруется в
PayloadCipher(PayloadContractVersion = 1,KeyVersion= текущий); открытые проекции:NumberMask(первые 2 + последние 3 символа, остальное*, напр.45** ***421),IssueDateиExpiresAt— копии из payload (у RfPassport/InnExpiresAt = null),CountryIsoCode2— код резолвленной страны (денормализация, юридический след),Title— генерируется («Паспорт РФ •• 421») либо из формы для Custom;ScanList = { ContractVersion: 1, Items: [] };Status = Active. Root:DocumentsCount + 1,Version + 1. - Публикуются
PersonDocumentAddedEvent(домен:PersonId,DocumentId,Type,NumberMask) иPersonUpsertedKafkaEventroot-а (отражает инкрементDocumentsCount; отдельных Kafka-событий документов нет — ../domain/person-document.md). Персона, документ и факт добавления фиксируются в одном согласованном изменении. - Возвращается
PersonDocumentResult { id, type, title, numberMask, issueDate?, expiresAt?, scansCount: 0 }— payload в ответе списка не расшифровывается; полный расшифрованный payload — отдельный self-запросGET /profile/persons/{id}/documents/{docId}.
- Проверить typed-модель payload до шифрования (INV-DOC-1) →
- Инвестор прикладывает скан: фронт загружает файл в storage (внешний сервис, граница —
presigned-upload;
profileфайлы не принимает) и получаетFileId. - Фронт вызывает
attachDocumentScan(POST /profile/persons/{personId}/documents/{documentId}/scans) сAttachDocumentScanForm { fileId (Guid), fileName (1..256), contentType (image/jpeg | image/png | image/webp | application/pdf), sizeBytes (1..20971520) }. - Прикрепление скана. Проверки доступа как в шаге 4; валидируется
DocumentScanList(≤ 10 элементов, белый списокContentType, размер ≤ 20 МБ — INV-DOC-5); в список добавляетсяDocumentScan { FileId, FileName, ContentType, SizeBytes, UploadedAt };Versionдокумента + 1; публикуетсяPersonDocumentUpdatedEvent. Результат — обновлённый список сканов (метаданные; скачивание — presigned-ссылка storage по требованию). - Фронт инвалидирует
listPersons/listPersonDocuments; карточка показывает документ с маской номера и превью сканов.
Альтернативные Потоки
A1. Иностранный паспорт (Грузия, Армения, ОАЭ и др.)
- Инвестор выбирает тип
ForeignPassport(илиCisPassportдля стран СНГ), указываетcountryId(страна Грузия/ОАЭ/Армения/… из справочника); payload —ForeignPassportModel { CountryId, Number 1..20, IssuedBy? ≤512, IssueDate, ExpiryDate?, LatinFullName? ≤256 }/CisPassportModel— дом моделей ../domain/person-document.md. - INV-DOC-9:
countryIdрезолвится в справочнике (неизвестная/архивная →PROFILE_COUNTRY_UNKNOWN). INV-DOC-2:countryIdформы =CountryIdpayload; дляCisPassportIsoCode2резолвленной страны — только AM/AZ/UZ/KG/TJ/MD/TM, иначеPROFILE_DOCUMENT_COUNTRY_MISMATCH.ExpiryDateкопируется в открытоеExpiresAt(job напоминаний об истечении). Возврат к шагу 4 основного потока.
A2. ИНН и кастомный документ
Inn: payloadInnModel { Inn ^\d{12}$ (контрольные цифры по алгоритму ФНС), CertificateNumber? ≤32, IssueDate? }; страна фиксирована —IsoCode2резолвленногоcountryIdобязан бытьRU(INV-DOC-2).Custom:titleобязателен (INV-DOC-3); payloadCustomDocumentModel { Title, Number? ≤64, IssuedBy?, IssueDate?, ExpiryDate?, Fields: List<CustomDocumentField {Key 1..64 уникален, Value 1..1024}> ≤ 30 };NumberHash— поNumber, при его отсутствии — поTitle.- Возврат к шагу 4 основного потока.
A3. Дубль документа
- У персоны уже есть активный документ того же
Typeс тем жеNumberHash→PROFILE_DOCUMENT_ALREADY_EXISTS(409); при гонке двух запросов проигравшего отклоняет уникальный индексux_person_documents_dedupeпри сохранении. - Фронт показывает существующий документ (по маске номера); инвестор редактирует его через
updatePersonDocumentвместо создания. Сценарий завершается.
A4. Правка данных документа (updatePersonDocument)
PUT /profile/persons/{personId}/documents/{documentId}c новой typed-моделью payload (typeне передаётся — иммутабелен, INV-DOC-7; смена типа = архив + новый документ).- Проверки доступа как в шаге 4; документ переваливается (INV-DOC-1/2/4), payload повторно
сериализуется и шифруется текущим ключом, пересчитываются
NumberHash/NumberMask/Title/IssueDate/ExpiresAt;Versionдокумента + 1; публикуетсяPersonDocumentUpdatedEvent(имена полей, без значений). Возврат к шагу 8.
A5. Архив документа/персоны
archivePersonDocument: архив документа запрещён, пока он указан в активном шаблоне реквизитов (INV-DOC-6 →PROFILE_DOCUMENT_USED_IN_TEMPLATE, 409); при успехеStatus = Archived,DeletedAt = now,DocumentsCount − 1.archivePerson: архив персоны запрещён при активных шаблонах (INV-PRS-4), активной верификации (INV-PRS-5) и дляRelation = Self(INV-PRS-6). Полное удаление PII — не self-service (регламент администратора платформы, ADR-0053: crypto-erase). Сценарий завершается.
A6. Лимиты превышены
PROFILE_PERSONS_LIMIT_EXCEEDED(20 персон, INV-PRS-7) илиPROFILE_DOCUMENTS_LIMIT_EXCEEDED(20 документов, INV-DOC-8) илиValidationErrorпо сканам (10 × 20 МБ, INV-DOC-5).- Фронт предлагает архивировать неиспользуемые записи. Сценарий завершается.
A7. Файл загружен в storage, но attachDocumentScan не выполнился (частичный отказ)
- Файл уже в storage (presigned-upload), но метаданные скана не сохранены в
profile(сетевой сбой либо отказ валидации INV-DOC-5): файл остаётся несвязанным (orphan) — на документ он не влияет, вScanListего нет. Очистка orphan-файлов — забота storage (lifecycle/GC);profileфайлы не хранит и не удаляет. - Повтор
attachDocumentScanс тем жеFileIdидемпотентен: скан с уже присутствующим вScanListFileIdне дублируется. Инвестор безопасно повторяет прикрепление после устранения ошибки.
Постусловие
- Создана активная персона
Relation = Spouseс шифрованными ФИО/датой рождения. - У персоны — активный документ: typed payload зашифрован целиком в
PayloadCipher(PayloadContractVersion = 1,KeyVersion= текущий), открыты толькоType,CountryId,CountryIsoCode2,Title,NumberMask,NumberHash,IssueDate?,ExpiresAt?;DocumentsCountroot-а = 1. - В
ScanList— записиFileIdstorage; сами файлы — вне БД profile. - Опубликованы
PersonUpsertedKafkaEvent(создание персоны, добавление документа) без PII. - Персона доступна для выбора в шаблонах реквизитов (UC-PRF-004).
Возможные Ошибки
| Код | HTTP | Условие | Поведение клиента |
|---|---|---|---|
ValidationError | 400 | Формат payload/сканов (INV-DOC-1/3/5) | Подсветить поля typed-формы |
PROFILE_MANAGED_EXTERNALLY | 403 | TalentSync (INV-PRS-3) | UC-PRF-005 |
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE | 403 | Не инвестор (INV-PRS-2) | Скрыть блок |
PROFILE_PERSONS_LIMIT_EXCEEDED | 409 | ≥ 20 активных персон (INV-PRS-7) | Предложить архив |
PROFILE_DOCUMENTS_LIMIT_EXCEEDED | 409 | ≥ 20 активных документов (INV-DOC-8) | Предложить архив |
PROFILE_DOCUMENT_COUNTRY_MISMATCH | 400 | IsoCode2 страны не соответствует типу (INV-DOC-2) | Подсветить страну |
PROFILE_COUNTRY_UNKNOWN | 422 | countryId не найден в справочнике или архивен (INV-DOC-9) | Обновить список стран (публичный lookup), исправить выбор |
PROFILE_DOCUMENT_ALREADY_EXISTS | 409 | Дубль NumberHash (INV-DOC-4) | Показать существующий документ |
PROFILE_DOCUMENT_USED_IN_TEMPLATE | 409 | Архив документа из активного шаблона (INV-DOC-6) | Показать шаблоны-блокеры |
PROFILE_PERSON_USED_IN_TEMPLATE | 409 | Архив персоны из активного шаблона (INV-PRS-4) | Показать шаблоны-блокеры |
PROFILE_PERSON_HAS_ACTIVE_VERIFICATION | 409 | Архив персоны при активной заявке KYC (INV-PRS-5) | Показать заявку |
PROFILE_SELF_PERSON_NOT_ARCHIVABLE | 409 | Архив персоны Self (INV-PRS-6) | Скрыть кнопку для Self |
CONCURRENCY_CONFLICT | 409 | Гонка RowVersion | Перечитать и повторить |
404 NOT_FOUND | 404 | Чужая/несуществующая персона или документ | Обновить список |
Доменные События
| Событие | Когда поднимается | Subscribers |
|---|---|---|
PersonCreatedEvent | Шаг 2 (создание персоны) | in-proc аудит |
PersonDocumentAddedEvent | Шаг 4 (AddDocument) | in-proc аудит |
PersonDocumentUpdatedEvent | Шаг 7 / A4 (сканы, payload) | in-proc аудит |
PersonDocumentArchivedEvent | A5 | in-proc аудит |
Kafka Интеграционные События
| Событие | Topic | Когда публикуется | Consumers |
|---|---|---|---|
PersonUpsertedKafkaEvent.v1 | pin.profile.persons.v1 | Создание персоны; изменение DocumentsCount (add/archive документа); архив персоны | notifications (будущее), внутренние проекции; PII не публикуется |
Acceptance Criteria
| # | Критерий | Проверка |
|---|---|---|
| AC1 | На один аккаунт создаются несколько персон (Spouse/Child/…); каждая со своими документами | интеграционный тест |
| AC2 | Payload документа в БД — только PayloadCipher (bytea); открыты только Type/CountryId/CountryIsoCode2/Title/NumberMask/NumberHash/даты | SQL-просмотр колонок |
| AC3 | Дубль паспорта (та же серия+номер) отклоняется, в т.ч. при конкурентной вставке | конкурентный тест unique-индекса |
| AC4 | Скан > 20 МБ / недопустимый ContentType / 11-й скан отклоняются валидацией | тест INV-DOC-5 |
| AC5 | DocumentsCount root-а корректен после add/archive документа | тест денормализации |
| AC6 | Typed-модель по каждому из 7 типов (RfPassport..Custom) валидируется по своей схеме | параметризованный тест |
| AC7 | Архив документа из активного шаблона запрещён (INV-DOC-6) | тест со связкой UC-PRF-004 |
| AC8 | В логах и Kafka нет значений документов (серии/номера) — только маски и id | аудит логов + контракт-тест |
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр use cases и user stories PIN
Связанные Документы
- API методы (будущие):
../api/public/create-person.md,../api/public/add-person-document.md,../api/public/update-person-document.md,../api/public/attach-document-scan.md,../api/public/archive-person-document.md. - Domain entity: ../domain/person.md (INV-PRS-2..8),
../domain/person-document.md (typed-модели payload, INV-DOC-1..8,
DocumentScanList). - Событие: ../events/person-upserted.md.
- Storage-граница: presigned upload/download — prototype-mapping.md.
- Связанные UC: UC-PRF-001 (предусловие), UC-PRF-004 (использование документов в шаблоне), UC-PRF-003 (документы в снапшоте верификации).