Entity: PersonDocumentEntity
Дом полей сущности «Документ персоны» контекста profile (child агрегата
PersonEntity). Снапшоты документов в шаблонах реквизитов и заявках на верификацию
ссылаются сюда и не переописывают модели полей.
Назначение
PersonDocumentEntity — один документ, привязанный к персоне: паспорт гражданина РФ, РБ, Казахстана,
других стран СНГ, иностранный паспорт (Грузия, Армения, ОАЭ и др.), ИНН, а также произвольный
кастомный документ. Значения полей документа хранятся в зашифрованном виде (шифрование на уровне
приложения): typed-модель полей (строго по типу документа, с ContractVersion) сериализуется в JSON и
шифруется целиком в колонку PayloadCipher. Открыто хранятся только неидентифицирующие атрибуты: тип,
страна, маска номера, HMAC-хэш номера (для поиска дублей без расшифровки), сканы (fileId сервиса storage)
и срок действия.
Сканы/фото документов загружаются в сервис storage (граница — presigned-ссылки); profile хранит
только FileId и метаданные файла.
Классификация
| Поле | Значение |
|---|
| Контекст | profile |
| Kind | Entity (child агрегата PersonEntity; мутации — только через root-агрегат: добавление / обновление / архив документа) |
| Source of truth | profile |
| Таблица | profile.person_documents |
| Tenant isolation | Не tenanted (ADR-0052); record-level по OwnerUserId (денормализация с root) |
Поля
| Поле | Тип | Обязательность | Ограничения (PK/FK/index/constraint) | Источник | Описание |
|---|
Id | Guid | Да | PK | app | Идентификатор документа. |
PersonId | Guid | Да | FK fk_person_documents_person_id → profile.persons(id), index ix_person_documents_person_id | app | Персона-владелец (root агрегата). |
OwnerUserId | Guid | Да | index ix_person_documents_owner_user_id | app (денормализация с PersonEntity.OwnerUserId) | Владелец аккаунта — для record-level фильтра без join. |
Type | PersonDocumentType | Да | varchar(32) (enum как текст), index ix_person_documents_type | app | Тип документа (см. enum ниже); определяет typed-модель PayloadCipher (INV-DOC-1). Иммутабелен после создания. |
Status | PersonDocumentStatus | Да | varchar(32) (enum как текст) | app | Active / Archived. |
CountryId | short | Да | ссылка на справочник стран handbook (country.md) | app | Страна выдачи документа. На write-path резолвится и валидируется через internal resolve справочника (resolve-references.md): неизвестная/архивная страна → PROFILE_COUNTRY_UNKNOWN (INV-DOC-9). Для RfPassport/RbPassport/KzPassport/Inn страна фиксирована по IsoCode2 (INV-DOC-2); для CisPassport/ForeignPassport/Custom выбирается пользователем. |
CountryIsoCode2 | string | Да | char(2), ISO 3166-1 alpha-2 | app (денормализация из резолва справочника при записи) | Код IsoCode2 страны выдачи, снятый при создании/обновлении документа, — юридический след и источник для снапшотов (реквизиты CRM, верификация) без обращения к справочнику на read-path. |
Title | string | Да | varchar(256) | app / генерируется | Отображаемое имя в списках: для стандартных типов — генерируется («Паспорт РФ •• 1234»); для Custom — вводится пользователем (обязателен, INV-DOC-3). |
PayloadCipher | byte[] | Да | bytea, not null | app (шифрование PII) | Зашифрованная JSON-сериализация typed-модели полей документа (модели ниже). Расшифровка — только в self-операциях, снапшоте реквизитов для CRM (internal API) и review верификации. |
PayloadContractVersion | int | Да | >= 1 | app | Версия typed-модели внутри PayloadCipher; миграция версий — lazy при чтении/записи. |
KeyVersion | int | Да | >= 1 | app (шифрование PII) | Версия ключа шифрования PayloadCipher. |
NumberHash | byte[] | Да | bytea; unique ux_person_documents_dedupe (person_id, type, number_hash) where status = 'Active' and deleted_at is null | app | HMAC-SHA-256 (выделенный ключ PersonalDataCrypto, не ключ шифрования) от нормализованного номера документа (серия+номер без пробелов, upper-case). Поиск дублей без расшифровки (INV-DOC-4). |
NumberMask | string | Да | varchar(32) | app | Маскированный номер для UI-списков и логов: первые 2 + последние 3 символа, остальное * (напр. 45** ***421). Единственная «открытая» проекция номера. |
IssueDate | DateOnly? | Нет | — | app (копия из payload) | Дата выдачи — открытая копия для сортировки списка (сама по себе не идентифицирует). |
ExpiresAt | DateOnly? | Нет | index ix_person_documents_expires_at where not null | app (копия из payload) | Срок действия — для подсветки «истекает» в ЛК; у бессрочных (паспорт РФ, ИНН) — null. |
ScanList | DocumentScanList (jsonb) | Да (может быть пустым списком) | jsonb, typed-модель ниже | app + storage | Сканы документа: контейнер ссылок FileId storage. Не шифруется: PII — в файлах storage, доступ к которым гейтится presigned-ссылками по permission. |
CreatedAt | DateTimeOffset | Да | — | app | Создание. |
CreatedByUserId | Guid? | Нет | — | app | Кто создал. |
UpdatedAt | DateTimeOffset | Да | — | app | Время последнего изменения. |
UpdatedByUserId | Guid? | Нет | — | app | Последний изменивший. |
DeletedAt | DateTimeOffset? | Нет | — | app | Soft-delete. |
DeletedByUserId | Guid? | Нет | — | app | Кто архивировал. |
Version | long | Да | >= 1 | app | Версия записи (оптимистическая блокировка). |
Enum PersonDocumentType → typed-модель payload
| Значение | Typed-модель (PayloadCipher) | Описание |
|---|
RfPassport | RfPassportModel | Паспорт гражданина РФ. |
RbPassport | RbPassportModel | Паспорт гражданина Республики Беларусь. |
KzPassport | KzPassportModel | Паспорт / удостоверение личности гражданина Казахстана (с ИИН). |
CisPassport | CisPassportModel | Паспорт гражданина прочих стран СНГ (AM, AZ, UZ, KG, TJ, MD…) — страна в CountryId. |
ForeignPassport | ForeignPassportModel | Паспорт иного государства (GE, AE и др.) — страна в CountryId. |
Inn | InnModel | ИНН (свидетельство о постановке на налоговый учёт, РФ). |
Custom | CustomDocumentModel | Кастомный документ с произвольным набором полей. |
Unknown enum handling: неизвестное значение Type при чтении — ошибка данных (fail-fast), новые типы
добавляются только с новой typed-моделью и миграцией контракта (additive).
Enum PersonDocumentStatus
| Значение | Описание |
|---|
Active | Используется в шаблонах/верификации. |
Archived | Скрыт; исторические снапшоты остаются валидными. |
Value objects
Отдельные VO не используются: все доменные значения документа — внутри зашифрованного typed
payload; открытые скалярные проекции (NumberMask, IssueDate, ExpiresAt) — обычные колонки.
Модели JSONB (versioned)
Payload-модели сериализуются в JSON и шифруются целиком в PayloadCipher (bytea), т.е. это
«typed jsonb за шифром»: схема строго типизирована и версионируется PayloadContractVersion, но в БД
JSON-операторы к ней неприменимы (осознанно: требование — хранить значения шифрованными).
ScanList — обычный plaintext jsonb.
RfPassportModel (payload, ContractVersion = 1) — Type = RfPassport
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Series | string | Да | ^\d{4}$ | v1 |
Number | string | Да | ^\d{6}$ | v1 |
IssuedBy | string | Да | 1–512 символов (кем выдан) | v1 |
DepartmentCode | string | Да | ^\d{3}-\d{3}$ (код подразделения) | v1 |
IssueDate | DateOnly | Да | ≤ сегодня | v1 |
BirthPlace | string? | Нет | ≤ 256 символов | v1 |
RegistrationAddress | string? | Нет | ≤ 512 символов (адрес регистрации/прописки) | v1 |
RbPassportModel (payload, ContractVersion = 1) — Type = RbPassport
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Series | string | Да | ^[A-Z]{2}$ (2 латинские буквы) | v1 |
Number | string | Да | ^\d{7}$ | v1 |
IdentificationNumber | string | Да | ^[0-9A-Z]{14}$ (идентификационный номер РБ) | v1 |
IssuedBy | string | Да | 1–512 символов | v1 |
IssueDate | DateOnly | Да | ≤ сегодня | v1 |
ExpiryDate | DateOnly | Да | > IssueDate; копируется в открытое ExpiresAt | v1 |
KzPassportModel (payload, ContractVersion = 1) — Type = KzPassport
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Number | string | Да | ^[A-Z]\d{8}$ (паспорт) либо ^\d{9}$ (удостоверение) | v1 |
Iin | string | Да | ^\d{12}$ (ИИН) | v1 |
IssuedBy | string | Да | 1–512 символов | v1 |
IssueDate | DateOnly | Да | ≤ сегодня | v1 |
ExpiryDate | DateOnly | Да | > IssueDate; → ExpiresAt | v1 |
CisPassportModel (payload, ContractVersion = 1) — Type = CisPassport
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
CountryId | short | Да | ссылка на справочник стран; IsoCode2 резолвленной страны ∈ AM/AZ/UZ/KG/TJ/MD/TM; дублирует открытое CountryId (консистентность — INV-DOC-2) | v1 |
Series | string? | Нет | ≤ 8 символов | v1 |
Number | string | Да | 1–16 символов, [0-9A-Za-z-] | v1 |
PersonalNumber | string? | Нет | ≤ 20 символов (личный/идентификационный номер, если есть) | v1 |
IssuedBy | string | Да | 1–512 символов | v1 |
IssueDate | DateOnly | Да | ≤ сегодня | v1 |
ExpiryDate | DateOnly? | Нет | > IssueDate; → ExpiresAt | v1 |
ForeignPassportModel (payload, ContractVersion = 1) — Type = ForeignPassport
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
CountryId | short | Да | ссылка на справочник стран (Грузия, ОАЭ и любые прочие); дублирует открытое CountryId | v1 |
Number | string | Да | 1–20 символов, [0-9A-Za-z-] | v1 |
IssuedBy | string? | Нет | ≤ 512 символов | v1 |
IssueDate | DateOnly | Да | ≤ сегодня | v1 |
ExpiryDate | DateOnly? | Нет | > IssueDate; → ExpiresAt | v1 |
LatinFullName | string? | Нет | ≤ 256 символов (имя латиницей как в паспорте/MRZ) | v1 |
InnModel (payload, ContractVersion = 1) — Type = Inn
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Inn | string | Да | ^\d{12}$ (физлицо; контрольные цифры валидируются алгоритмом ФНС) | v1 |
CertificateNumber | string? | Нет | ≤ 32 символов (номер свидетельства) | v1 |
IssueDate | DateOnly? | Нет | ≤ сегодня | v1 |
CustomDocumentModel (payload, ContractVersion = 1) — Type = Custom
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Title | string | Да | 1–256 символов; дублируется в открытое Title | v1 |
Number | string? | Нет | ≤ 64 символов; если задан — участвует в NumberHash, иначе hash по Title | v1 |
IssuedBy | string? | Нет | ≤ 512 символов | v1 |
IssueDate | DateOnly? | Нет | ≤ сегодня | v1 |
ExpiryDate | DateOnly? | Нет | > IssueDate; → ExpiresAt | v1 |
Fields | List<CustomDocumentField> | Да (может быть пустым) | ≤ 30 элементов | v1 |
CustomDocumentField (элемент контейнера)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
Key | string | Да | 1–64 символов; уникален в пределах Fields | v1 |
Value | string | Да | 1–1024 символов | v1 |
DocumentScanList (jsonb plaintext, ContractVersion = 1)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
ContractVersion | int | Да | = 1 | v1 |
Items | List<DocumentScan> | Да (может быть пустым) | ≤ 10 элементов (INV-DOC-5) | v1 |
DocumentScan (элемент контейнера)
| Поле | Тип | Обязательность | Ограничения | Since version |
|---|
FileId | Guid | Да | id файла в сервисе storage (скачивание — presigned-ссылка) | v1 |
FileName | string | Да | 1–256 символов | v1 |
ContentType | string | Да | из списка: image/jpeg, image/png, image/webp, application/pdf | v1 |
SizeBytes | long | Да | 1 – 20 971 520 (20 МБ) | v1 |
UploadedAt | DateTimeOffset | Да | — | v1 |
Правило совместимости payload-моделей: изменения — только additive (новые nullable-поля с ростом
ContractVersion); чтение старой версии мигрирует в актуальную модель в памяти; breaking — новый
PersonDocumentType.
Связи
| Связь | Тип | Ключ | Правило загрузки | Delete behavior |
|---|
PersonEntity | root агрегата | PersonId (FK) | документ загружается включённым в root (GetById(personId) + include) | cascade (архив root архивирует child) |
RequisiteTemplateEntity | reference (обратная, по id в composition) | RequisiteTemplateComposition.*DocumentId | Filter(spec) в guard INV-DOC-6 | restrict |
| storage-файлы | внешняя граница | ScanList.Items[].FileId | presigned-ссылка через storage API | файлы удаляются retention-политикой storage после архива документа |
Инварианты и переходы состояний
| Инвариант | Условие | Где проверяется | Ошибка |
|---|
| INV-DOC-1 TypedPayload | PayloadCipher расшифровывается строго в модель, соответствующую Type (таблица enum выше); проверка модели — до шифрования | при добавлении/обновлении документа (единая точка сериализации и шифрования) | ValidationError |
| INV-DOC-2 CountryConsistency | IsoCode2 страны, резолвленной по CountryId через справочник: RfPassport/Inn → RU, RbPassport → BY, KzPassport → KZ; CisPassport → ∈ {AM, AZ, UZ, KG, TJ, MD, TM}; для CisPassport/ForeignPassport CountryId внутри payload совпадает с открытым CountryId | при добавлении/обновлении документа (после резолва INV-DOC-9) | PROFILE_DOCUMENT_COUNTRY_MISMATCH |
| INV-DOC-3 CustomTitle | Для Type = Custom Title вводится пользователем (1–256), для прочих — генерируется из типа + NumberMask | при добавлении документа | ValidationError |
| INV-DOC-4 NoDuplicates | Активный дубль (одинаковый NumberHash при равных PersonId+Type) запрещён | unique partial index ux_person_documents_dedupe + предварительная проверка дубля | PROFILE_DOCUMENT_ALREADY_EXISTS |
| INV-DOC-5 ScansLimit | ≤ 10 сканов, каждый ≤ 20 МБ, ContentType из белого списка | проверка входа DocumentScanList | ValidationError |
| INV-DOC-6 ArchiveNotReferenced | Active → Archived запрещён, пока документ указан в активном шаблоне реквизитов | при архиве документа (проверка ссылок в активных шаблонах) | PROFILE_DOCUMENT_USED_IN_TEMPLATE |
| INV-DOC-7 TypeImmutable | Type не меняется после создания (смена типа = новый документ) | тип не принимается операцией обновления | — |
| INV-DOC-8 DocumentsLimit | ≤ 20 активных документов на персону (simplicity-first, агрегат загружается целиком) | при добавлении документа (проверка счётчика) | PROFILE_DOCUMENTS_LIMIT_EXCEEDED |
| INV-DOC-9 CountryRef | CountryId существует в справочнике стран и не архивен (write-path-резолв через internal resolve handbook, resolve-references.md); CountryIsoCode2 = IsoCode2 резолвленной записи (денормализация при записи) | при добавлении/обновлении документа | PROFILE_COUNTRY_UNKNOWN |
Архив документа (INV-DOC-6) блокирует только ссылка из активного шаблона реквизитов. Документ, попавший
лишь в иммутабельный снапшот — снапшот реквизитов сделки CRM или снапшот заявки верификации на момент
Submit — архивируется свободно: снапшот содержит собственную копию значений (DocumentSnapshot,
verification-request.md) и от статуса исходного документа не зависит.
Доменные и интеграционные события
| Событие | Тип | Когда | Snapshot/поля |
|---|
PersonDocumentAddedEvent | доменное (поднимает root PersonEntity) | добавление документа | PersonId, DocumentId, Type, NumberMask |
PersonDocumentUpdatedEvent | доменное | обновление документа | PersonId, DocumentId, список изменённых полей (имена, без значений) |
PersonDocumentArchivedEvent | доменное | архив документа | PersonId, DocumentId |
| — | интеграционное | Отдельные Kafka-события документов не публикуются | Изменение документов отражается инкрементом DocumentsCount в PersonUpsertedKafkaEvent root-а (person.md); содержимое документов не покидает контекст иначе как через internal API снапшота реквизитов |
Индексы, хранение, безопасность
| Аспект | Значение |
|---|
| Индексы | PK (Id); ix_person_documents_person_id; ix_person_documents_owner_user_id; ix_person_documents_type; ux_person_documents_dedupe unique (person_id, type, number_hash) where status='Active' and deleted_at is null; ix_person_documents_expires_at where expires_at is not null (job напоминаний об истечении) |
| Партиционирование / retention | none; документы — бессрочно (soft-delete); crypto-erase при удалении PII по регламенту |
| Concurrency | оптимистическая блокировка записи (правка не должна затирать чужую) + версия root-а |
| Permissions / PII | Все значения документа — только в PayloadCipher (AES-256-GCM, PersonalDataCrypto, ключи в Vault; отдельный HMAC-ключ для NumberHash); наружу — только NumberMask/Title; логи — без значений (только DocumentId, Type); self — profile.documents.view-self/profile.documents.manage-self; расшифровка для CRM-сделки — internal API снапшота (requisite-template.md), m2m-actor; забота — profile.personal.view-any, значения маскируются, каждый доступ аудируется |
| Audit | Version + actor-stamping + доменные события без значений |
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (4): Entity / Aggregate: PersonEntity, Entity / Aggregate: ProfileEntity, Entity / Aggregate: RequisiteTemplateEntity, Entity / Aggregate: VerificationRequestEntity
- API (15): DELETE /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId} — Удаление скана документа (removeDocumentScan), GET /api/v1/admin/profile/verification/{requestId}/snapshot — Аудируемый просмотр снапшота KYC (viewVerificationSnapshot), GET /api/v1/profile/persons/{personId}/documents — Список документов персоны (listPersonDocuments), GET /api/v1/profile/persons/{personId}/documents/{documentId}/scans/{fileId}/download-link — Presigned-ссылка на скан (getDocumentScanLink), POST /api/v1/internal/profile/requisite-snapshot — Снапшот реквизитов для CRM (getRequisiteSnapshot + RegisterUsage), POST /api/v1/profile/persons/{personId}/archive — Архивация персоны (archivePerson), POST /api/v1/profile/persons/{personId}/documents — Добавление документа персоны (addPersonDocument), POST /api/v1/profile/persons/{personId}/documents/{documentId}/archive — Архивация документа персоны (archivePersonDocument), POST /api/v1/profile/persons/{personId}/documents/{documentId}/scans — Прикрепление скана документа (attachDocumentScan), POST /api/v1/profile/requisite-templates — Создание шаблона реквизитов (createRequisiteTemplate), POST /api/v1/profile/verification/{requestId}/submit — Отправка заявки KYC на ревью (submitVerification), Profile API — реестр операций областей public / internal / admin (+3)
- Use Cases (3): UC-PRF-002. Добавление персоны «Супруг(а)» с паспортом и сканами, UC-PRF-003. Подача заявки на верификацию инвестора и её одобрение (KYC), UC-PRF-004. Создание шаблона реквизитов и его использование в бронировании (CRM)
- События (1): Event Contract: pin.profile.person-upserted (PersonUpsertedKafkaEvent)
- Фоновые задачи (2): Background Job: DocumentExpiryReminderJob, Background Job: ReEncryptRotationJob
- registries (2): Реестр инвариантов PIN, Реестр сущностей PIN
- index.md (1): Profile Service (Pin.Profile)
Связанные документы