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

UC-PRF-002. Добавление персоны «Супруг(а)» с паспортом и сканами

Назначение

Добавление второго набора персональных данных на аккаунт: один аккаунт может принадлежать супругам, а сделку оформлять на супруга или детей. Инвестор создаёт персону Relation = Spouse, добавляет её документ (паспорт РФ/РБ/КЗ/СНГ/иностранный, ИНН, кастомный — typed payload, шифруется целиком в PayloadCipher) и прикрепляет сканы через сервис storage. Связан с контекстом profile.

Metadata

ПолеЗначение
IDUC-PRF-002
Контекстprofile (см. docs/06-services/profile/)
Типосновной
Статусdraft

Актор

ПолеЗначение
РольИнвестор (только он управляет персонами/документами — INV-PRS-2)
Permissions / scopeprofile.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 → «Добавить персону»; карточка персоны → «Добавить документ»; форма документа → «Прикрепить скан»

Основной Поток

  1. Инвестор нажимает «Добавить персону», заполняет форму (связь = «Супруг(а)», ФИО, дата рождения, гражданство) и сохраняет. Фронт вызывает 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.)
  2. Создание персоны.
    • Проверить вход до шифрования: длины, 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); фронт открывает карточку персоны.
  3. Инвестор нажимает «Добавить документ», выбирает тип «Паспорт РФ» и заполняет 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.
  4. Добавление документа.
    • Проверить 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 формы = CountryId payload; нарушение → 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); совпадение с активным документом того же TypePROFILE_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/Inn ExpiresAt = null), CountryIsoCode2 — код резолвленной страны (денормализация, юридический след), Title — генерируется («Паспорт РФ •• 421») либо из формы для Custom; ScanList = { ContractVersion: 1, Items: [] }; Status = Active. Root: DocumentsCount + 1, Version + 1.
    • Публикуются PersonDocumentAddedEvent (домен: PersonId, DocumentId, Type, NumberMask) и PersonUpsertedKafkaEvent root-а (отражает инкремент DocumentsCount; отдельных Kafka-событий документов нет — ../domain/person-document.md). Персона, документ и факт добавления фиксируются в одном согласованном изменении.
    • Возвращается PersonDocumentResult { id, type, title, numberMask, issueDate?, expiresAt?, scansCount: 0 } — payload в ответе списка не расшифровывается; полный расшифрованный payload — отдельный self-запрос GET /profile/persons/{id}/documents/{docId}.
  5. Инвестор прикладывает скан: фронт загружает файл в storage (внешний сервис, граница — presigned-upload; profile файлы не принимает) и получает FileId.
  6. Фронт вызывает 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) }.
  7. Прикрепление скана. Проверки доступа как в шаге 4; валидируется DocumentScanList (≤ 10 элементов, белый список ContentType, размер ≤ 20 МБ — INV-DOC-5); в список добавляется DocumentScan { FileId, FileName, ContentType, SizeBytes, UploadedAt }; Version документа + 1; публикуется PersonDocumentUpdatedEvent. Результат — обновлённый список сканов (метаданные; скачивание — presigned-ссылка storage по требованию).
  8. Фронт инвалидирует listPersons/listPersonDocuments; карточка показывает документ с маской номера и превью сканов.

Альтернативные Потоки

A1. Иностранный паспорт (Грузия, Армения, ОАЭ и др.)

  1. Инвестор выбирает тип ForeignPassport (или CisPassport для стран СНГ), указывает countryId (страна Грузия/ОАЭ/Армения/… из справочника); payload — ForeignPassportModel { CountryId, Number 1..20, IssuedBy? ≤512, IssueDate, ExpiryDate?, LatinFullName? ≤256 } / CisPassportModel — дом моделей ../domain/person-document.md.
  2. INV-DOC-9: countryId резолвится в справочнике (неизвестная/архивная → PROFILE_COUNTRY_UNKNOWN). INV-DOC-2: countryId формы = CountryId payload; для CisPassport IsoCode2 резолвленной страны — только AM/AZ/UZ/KG/TJ/MD/TM, иначе PROFILE_DOCUMENT_COUNTRY_MISMATCH. ExpiryDate копируется в открытое ExpiresAt (job напоминаний об истечении). Возврат к шагу 4 основного потока.

A2. ИНН и кастомный документ

  1. Inn: payload InnModel { Inn ^\d{12}$ (контрольные цифры по алгоритму ФНС), CertificateNumber? ≤32, IssueDate? }; страна фиксирована — IsoCode2 резолвленного countryId обязан быть RU (INV-DOC-2).
  2. Custom: title обязателен (INV-DOC-3); payload CustomDocumentModel { Title, Number? ≤64, IssuedBy?, IssueDate?, ExpiryDate?, Fields: List<CustomDocumentField {Key 1..64 уникален, Value 1..1024}> ≤ 30 }; NumberHash — по Number, при его отсутствии — по Title.
  3. Возврат к шагу 4 основного потока.

A3. Дубль документа

  1. У персоны уже есть активный документ того же Type с тем же NumberHashPROFILE_DOCUMENT_ALREADY_EXISTS (409); при гонке двух запросов проигравшего отклоняет уникальный индекс ux_person_documents_dedupe при сохранении.
  2. Фронт показывает существующий документ (по маске номера); инвестор редактирует его через updatePersonDocument вместо создания. Сценарий завершается.

A4. Правка данных документа (updatePersonDocument)

  1. PUT /profile/persons/{personId}/documents/{documentId} c новой typed-моделью payload (type не передаётся — иммутабелен, INV-DOC-7; смена типа = архив + новый документ).
  2. Проверки доступа как в шаге 4; документ переваливается (INV-DOC-1/2/4), payload повторно сериализуется и шифруется текущим ключом, пересчитываются NumberHash/NumberMask/Title/ IssueDate/ExpiresAt; Version документа + 1; публикуется PersonDocumentUpdatedEvent (имена полей, без значений). Возврат к шагу 8.

A5. Архив документа/персоны

  1. archivePersonDocument: архив документа запрещён, пока он указан в активном шаблоне реквизитов (INV-DOC-6 → PROFILE_DOCUMENT_USED_IN_TEMPLATE, 409); при успехе Status = Archived, DeletedAt = now, DocumentsCount − 1.
  2. archivePerson: архив персоны запрещён при активных шаблонах (INV-PRS-4), активной верификации (INV-PRS-5) и для Relation = Self (INV-PRS-6). Полное удаление PII — не self-service (регламент администратора платформы, ADR-0053: crypto-erase). Сценарий завершается.

A6. Лимиты превышены

  1. PROFILE_PERSONS_LIMIT_EXCEEDED (20 персон, INV-PRS-7) или PROFILE_DOCUMENTS_LIMIT_EXCEEDED (20 документов, INV-DOC-8) или ValidationError по сканам (10 × 20 МБ, INV-DOC-5).
  2. Фронт предлагает архивировать неиспользуемые записи. Сценарий завершается.

A7. Файл загружен в storage, но attachDocumentScan не выполнился (частичный отказ)

  1. Файл уже в storage (presigned-upload), но метаданные скана не сохранены в profile (сетевой сбой либо отказ валидации INV-DOC-5): файл остаётся несвязанным (orphan) — на документ он не влияет, в ScanList его нет. Очистка orphan-файлов — забота storage (lifecycle/GC); profile файлы не хранит и не удаляет.
  2. Повтор attachDocumentScan с тем же FileId идемпотентен: скан с уже присутствующим в ScanList FileId не дублируется. Инвестор безопасно повторяет прикрепление после устранения ошибки.

Постусловие

  • Создана активная персона Relation = Spouse с шифрованными ФИО/датой рождения.
  • У персоны — активный документ: typed payload зашифрован целиком в PayloadCipher (PayloadContractVersion = 1, KeyVersion = текущий), открыты только Type, CountryId, CountryIsoCode2, Title, NumberMask, NumberHash, IssueDate?, ExpiresAt?; DocumentsCount root-а = 1.
  • В ScanList — записи FileId storage; сами файлы — вне БД profile.
  • Опубликованы PersonUpsertedKafkaEvent (создание персоны, добавление документа) без PII.
  • Персона доступна для выбора в шаблонах реквизитов (UC-PRF-004).

Возможные Ошибки

КодHTTPУсловиеПоведение клиента
ValidationError400Формат payload/сканов (INV-DOC-1/3/5)Подсветить поля typed-формы
PROFILE_MANAGED_EXTERNALLY403TalentSync (INV-PRS-3)UC-PRF-005
PROFILE_PERSONS_NOT_ALLOWED_FOR_USER_TYPE403Не инвестор (INV-PRS-2)Скрыть блок
PROFILE_PERSONS_LIMIT_EXCEEDED409≥ 20 активных персон (INV-PRS-7)Предложить архив
PROFILE_DOCUMENTS_LIMIT_EXCEEDED409≥ 20 активных документов (INV-DOC-8)Предложить архив
PROFILE_DOCUMENT_COUNTRY_MISMATCH400IsoCode2 страны не соответствует типу (INV-DOC-2)Подсветить страну
PROFILE_COUNTRY_UNKNOWN422countryId не найден в справочнике или архивен (INV-DOC-9)Обновить список стран (публичный lookup), исправить выбор
PROFILE_DOCUMENT_ALREADY_EXISTS409Дубль NumberHash (INV-DOC-4)Показать существующий документ
PROFILE_DOCUMENT_USED_IN_TEMPLATE409Архив документа из активного шаблона (INV-DOC-6)Показать шаблоны-блокеры
PROFILE_PERSON_USED_IN_TEMPLATE409Архив персоны из активного шаблона (INV-PRS-4)Показать шаблоны-блокеры
PROFILE_PERSON_HAS_ACTIVE_VERIFICATION409Архив персоны при активной заявке KYC (INV-PRS-5)Показать заявку
PROFILE_SELF_PERSON_NOT_ARCHIVABLE409Архив персоны Self (INV-PRS-6)Скрыть кнопку для Self
CONCURRENCY_CONFLICT409Гонка RowVersionПеречитать и повторить
404 NOT_FOUND404Чужая/несуществующая персона или документОбновить список

Доменные События

СобытиеКогда поднимаетсяSubscribers
PersonCreatedEventШаг 2 (создание персоны)in-proc аудит
PersonDocumentAddedEventШаг 4 (AddDocument)in-proc аудит
PersonDocumentUpdatedEventШаг 7 / A4 (сканы, payload)in-proc аудит
PersonDocumentArchivedEventA5in-proc аудит

Kafka Интеграционные События

СобытиеTopicКогда публикуетсяConsumers
PersonUpsertedKafkaEvent.v1pin.profile.persons.v1Создание персоны; изменение DocumentsCount (add/archive документа); архив персоныnotifications (будущее), внутренние проекции; PII не публикуется

Acceptance Criteria

#КритерийПроверка
AC1На один аккаунт создаются несколько персон (Spouse/Child/…); каждая со своими документамиинтеграционный тест
AC2Payload документа в БД — только PayloadCipher (bytea); открыты только Type/CountryId/CountryIsoCode2/Title/NumberMask/NumberHash/датыSQL-просмотр колонок
AC3Дубль паспорта (та же серия+номер) отклоняется, в т.ч. при конкурентной вставкеконкурентный тест unique-индекса
AC4Скан > 20 МБ / недопустимый ContentType / 11-й скан отклоняются валидациейтест INV-DOC-5
AC5DocumentsCount root-а корректен после add/archive документатест денормализации
AC6Typed-модель по каждому из 7 типов (RfPassport..Custom) валидируется по своей схемепараметризованный тест
AC7Архив документа из активного шаблона запрещён (INV-DOC-6)тест со связкой UC-PRF-004
AC8В логах и Kafka нет значений документов (серии/номера) — только маски и idаудит логов + контракт-тест

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

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

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

  • 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 (документы в снапшоте верификации).