POST /api/investor/crm/deals/{dealId}/escrow — Указать эскроу-счёт
Инвестор указывает номер эскроу-счёта по своей сделке (US-INV-19; для сделок ДДУ эскроу обязателен,
INV-DEAL-7). Проставляет EscrowAccountNumber (Sensitive, хранится в открытом виде, наружу — маска
****NNNN), переводит EscrowStatus None → Provided («Эскроу забронирован») и добавляет строку таймлайна
EscrowProvided (DealEntity). Kafka-события операция не публикует (доменное
изменение внутри crm); банковская интеграция не реализуется (решение 9 ТЗ) — номер сохраняется без
верификации в банке.
Назначение
Дать инвестору-участнику сделки самостоятельно внести номер эскроу-счёта, открытого им в банке проведения
сделки (US-INV-07/19). Эскроу применяется к договору ДДУ; менеджер застройщика (SellManager) использует
полный номер для брони счёта, а инвестор/агент видят только маску.
Metadata
| Поле | Значение |
|---|---|
| Сервис/контекст | crm |
| API area | investor |
| Feature group | deals |
| Статус | draft |
| Документ | docs/06-services/crm/api/investor/provide-escrow.md |
Актор и доступ
| Проверка | Значение |
|---|---|
| Actor type | User (Инвестор — участник сделки) |
| Auth policy / Permissions | crm.deals.manage (record-level self-service инвестора; JWT identity) |
| Scope (RBAC) | не применяется как tenant-scope: инвестор — вне tenant сделки (TenantId = DeveloperCompanyId) |
| Record-level | deal.PrimaryInvestorUserId == actor (инвестор-владелец); чужая сделка → CRM_DEAL_NOT_FOUND |
| Tenant isolation | tenant сделки = застройщик; из identity инвестора tenant не берётся, доступ — record-level по PrimaryInvestorUserId |
| Audit | update (sensitive): EscrowAccountNumber, EscrowStatus, UpdatedByUserId; номер счёта в логи не пишется |
Выполнить операцию может только инвестор — владелец сделки (deal.PrimaryInvestorUserId == actor); tenant
сделки (застройщик) в проверке не участвует — доступ полностью record-level. Чужая сделка возвращает 404 CRM_DEAL_NOT_FOUND, а не 403 — операция не раскрывает существование сделок других инвесторов.
HTTP-контракт
| Поле | Значение |
|---|---|
| Method | POST |
| Route | /api/investor/crm/deals/{dealId}/escrow |
| Success status | 200 (обновлённая карточка сделки) |
| Idempotency header | не применяется (бизнес-идемпотентность по EscrowStatus+EscrowAccountNumber) |
| Correlation | сквозной trace через заголовок traceparent |
Отдельного idempotency-ключа нет: бизнес-идемпотентность — по паре EscrowStatus+EscrowAccountNumber.
Повторный вызов с тем же нормализованным номером при уже EscrowStatus = Provided — no-op, возвращается
текущая карточка (200) без новой строки таймлайна и без доменного события.
Входные данные / параметры запроса
| Поле | Источник | Тип | Обязательность | Валидация / Ограничения | Безопасность |
|---|---|---|---|---|---|
DealId | route | Guid | Да | != Guid.Empty; существующая сделка инвестора | — |
EscrowAccountNumber | body | string | Да | trim; 1..64; только цифры ^\d{1,64}$ (номер счёта) | Sensitive: наружу маска ****NNNN; в логи не пишется |
ActorUserId | identity claims | Guid | Да | JWT инвестора; сверяется с deal.PrimaryInvestorUserId (record-level) | пишется в UpdatedByUserId/таймлайн |
Тело — { escrowAccountNumber }; DealId — из route, ActorUserId — из identity.
Модель ответа DealCardModel
Возвращается обновлённая карточка сделки (проекция /profile/deals/[id] инвестора). Дом полей — сущность
DealEntity; ниже — только дельта этой операции.
| Поле | Тип | Обязательность | Источник | Masking | Описание |
|---|---|---|---|---|---|
Id | Guid | Да | DealEntity.Id | — | Идентификатор сделки. |
Status | DealStatus | Да | DealEntity.Status (enum строкой) | — | Статус процесса без изменений (эскроу — атрибут, не переход статуса). |
ContractType | DealContractType? | Нет | DealEntity.ContractType (enum строкой) | — | Ddu/Dkp; эскроу применим только к Ddu. |
BankId | Guid? | Нет | DealEntity.BankId | — | Банк проведения (задаёт менеджер застройщика, US-INV-07); здесь read-only. |
EscrowStatus | EscrowStatus | Да | DealEntity.EscrowStatus (enum строкой) | — | После операции — Provided («Эскроу забронирован»). |
EscrowAccountNumberMasked | string? | Нет | computed из DealEntity.EscrowAccountNumber | ****NNNN (последние 4) | Маскированный номер для инвестора/агента. Полный номер наружу не отдаётся. |
UpdatedAt | DateTimeOffset | Да | DealEntity.UpdatedAt | — | US-INV-04. |
Полный EscrowAccountNumber доступен отдельной привилегированной проекцией только SellManager/PinManager
(см. deal.md, «Безопасность»); этот инвесторский endpoint всегда отдаёт маску.
Алгоритм
- Контекст и доступ. Операцию выполняет инвестор (permission
crm.deals.manage, иначеCRM_DEALS_FORBIDDEN);ActorUserId— из токена (tenant инвестора не участвует). Номер счёта нормализуется (убираются пробелы). Бизнес-идемпотентность: если у сделки ужеEscrowStatus == Providedи хранимый номер совпадает с нормализованным — повтор; вернуть текущую карточку (200) без нового таймлайн/доменного события. - Проверка входа и предусловий.
DealId != Guid.Empty;EscrowAccountNumberне пустой после trim,1..64, только цифры^\d{1,64}$— иначеVALIDATION_FAILED. Существование, record-level и допустимость состояния — по загруженной сделке ниже. - Что читаем. Сделку инвестора под запись — по record-level предикату
PrimaryInvestorUserId == ActorUserIdиDeletedAt == null(scope-bypass developer-tenant, ADR-0052); нет совпадения →CRM_DEAL_NOT_FOUND(чужая сделка не раскрывается). Допустимость состояния по сделке: терминальныеCompleted/Cancelled→CRM_DEAL_ALREADY_TERMINAL(INV-DEAL-9);RosreestrRegisteredAt == null, иначеCRM_DEAL_ESCROW_LOCKED(после регистрации счёт менять нельзя); применимостьContractType != Dkp(null илиDdu), иначеCRM_DEAL_ESCROW_NOT_APPLICABLE(эскроу — только для ДДУ). Банковский вызов не выполняется (решение 9). - Бизнес-правила и переходы. Проставить
EscrowAccountNumber = normalized(хранится открыто),EscrowStatus = None → Provided,UpdatedByUserId = actor,UpdatedAt = now. Вычислить маскуAccountNumberMasked = "****" + last4(normalized)(при длине ≤ 4 — маскируется полностью). Добавляется строка таймлайнаEscrowProvided(Status = Done,OccurredAt = now,ActorRole = InvestorPrimary,Payload = TimelineEscrowPayload { BankId, AccountNumberMasked }— только маска, deal-timeline-event.md). - Что меняется. В одном согласованном изменении (INV-TLE-2): обновляются поля эскроу на сделке +
добавляется строка
deal_timeline_events(append-only, INV-TLE-1). Интеграционных Kafka-событий нет — смена эскроу-статуса не покидаетcrm(нет downstream-контекста; статус объекта/банк не затрагиваются); строка таймлайна видна участникам сделки, включая менеджера застройщика (guard перед Росреестром — INV-DEAL-7), интеграционного пуша нет. Параллельная попытка изменить ту же сделку →CRM_DEAL_CONCURRENCY_CONFLICT(перечитать и повторить). - Результат. Обновлённая карточка
DealCardModel(эскроу-номер → маска****NNNN),200. Идемпотентный повтор (шаг 1) —200с текущей карточкой без нового события.
Побочные эффекты
| Тип | Детали |
|---|---|
| Integration events | none (доменная операция внутри crm; kafka-события не публикуются — эскроу-статус не покидает контекст) |
| External calls | none (банковская интеграция не реализуется — решение 9 ТЗ; номер счёта не верифицируется в банке) |
| Cache / projections / search | none (проекция сделки не затрагивается — Title/ФИО/статус не меняются) |
| Notifications | опосредованно: таймлайн-строка EscrowProvided видна участникам сделки в карточке (менеджеру застройщика — для брони счёта в банке); отдельного интеграционного пуша нет |
Ошибки и failure modes
| Код | HTTP status | Условие | Сообщение | Retry |
|---|---|---|---|---|
VALIDATION_FAILED | 400 | DealId = Guid.Empty или номер не проходит ^\d{1,64}$ | «Проверьте номер эскроу-счёта» | no retry |
UNAUTHORIZED | 401 | нет/просрочен JWT | «Требуется авторизация» | no retry |
CRM_DEALS_FORBIDDEN | 403 | нет permission crm.deals.manage | «Недостаточно прав» | no retry |
CRM_DEAL_NOT_FOUND | 404 | сделки нет или инвестор не её участник (record-level) | «Сделка не найдена» | no retry |
CRM_DEAL_ESCROW_NOT_APPLICABLE | 409 | ContractType = Dkp (эскроу только для ДДУ) | «Эскроу-счёт неприменим для этого договора» | no retry |
CRM_DEAL_ESCROW_LOCKED | 409 | RosreestrRegisteredAt != null (счёт уже зафиксирован при регистрации) | «Изменение эскроу недоступно» | no retry |
CRM_DEAL_ALREADY_TERMINAL | 409 | сделка Completed/Cancelled (INV-DEAL-9) | «Сделка завершена или отменена» | no retry |
CRM_DEAL_CONCURRENCY_CONFLICT | 409 | конкурентное изменение той же сделки | «Повторите попытку» | retry (перечитка) |
Сделка другого инвестора возвращает 404 CRM_DEAL_NOT_FOUND, а не 403 — операция не раскрывает
существование чужих сделок. Это тот же код, что и для реально несуществующей сделки.
Предусловия и постусловия
Предусловия (истинны до сценария):
- инвестор — участник сделки (
deal.PrimaryInvestorUserId = actor) с permissioncrm.deals.manage; - сделка нетерминальна (
Status ∉ {Completed, Cancelled}, INV-DEAL-9), не зарегистрирована в Росреестре (RosreestrRegisteredAt = null, иначе счёт заблокирован), договор не ДКП (эскроу применим к ДДУ, INV-DEAL-7); - банковская верификация не выполняется (решение 9 ТЗ) — номер сохраняется без проверки в банке.
Постусловия (гарантированы после успеха):
EscrowAccountNumberсохранён (в открытом виде),EscrowStatus:None → Provided,UpdatedByUserId/UpdatedAtобновлены;Statusпроцесса не меняется (эскроу — атрибут, не переход);- в ленту добавлена append-only строка таймлайна
EscrowProvided(только маска****NNNN), видна участникам сделки, включаяSellManager— для брони счёта в банке до регистрации в Росреестре (INV-DEAL-7); - Kafka-события не публикуются (эскроу-статус не покидает
crm).
Acceptance Criteria
- Happy path (ДДУ). Дано: сделка
ContractType = Ddu(илиnull),EscrowStatus = None,RosreestrRegisteredAt = null. Когда:POST …/escrowс валидным номером40817…. Тогда:200,EscrowStatus = Provided, в ответе маска****NNNN, добавлена строка таймлайнаEscrowProvided, Kafka-событий нет. - Идемпотентный повтор. Дано: у сделки уже
EscrowStatus = Providedс тем же нормализованным номером. Когда: повторныйPOSTс тем же номером. Тогда:200c текущей карточкой, новая строка таймлайна не добавляется, доменное/интеграционное событие не поднимается. - Договор ДКП. Дано:
ContractType = Dkp. Когда:POST …/escrow. Тогда:409 CRM_DEAL_ESCROW_NOT_APPLICABLE. - После регистрации. Дано:
RosreestrRegisteredAt != null. Когда:POST …/escrow. Тогда:409 CRM_DEAL_ESCROW_LOCKED(счёт зафиксирован при регистрации). - Терминальная сделка. Дано:
Status ∈ {Completed, Cancelled}. Когда:POST …/escrow. Тогда:409 CRM_DEAL_ALREADY_TERMINAL. - Невалидный номер. Когда: номер содержит буквы/пустой после trim/длиннее 64. Тогда:
400 VALIDATION_FAILED; номер счёта в логи не пишется. - Чужая сделка. Дано: актор не участник сделки. Когда:
POST …/escrow. Тогда:404 CRM_DEAL_NOT_FOUND(чужая сделка не раскрывается). - Короткий номер. Дано: номер ≤ 4 цифр. Когда: успешное сохранение. Тогда: маска скрывает номер полностью.
- Конкурентное изменение. Дано: ту же сделку параллельно меняет другой участник. Когда:
POST …/escrow. Тогда:409 CRM_DEAL_CONCURRENCY_CONFLICT, проигравший перечитывает и повторяет.
Совместимость и наблюдаемость
- Тело
{ escrowAccountNumber }и200-карточка стабильны; новые optional-поляDealCardModel— additive; удаление/переименование/смена типа поля или значения enum — breaking (migration + rollout). Kafka-контракты операция не публикует, поэтому событийной совместимости не вводит. - Logs:
deal escrow provided(DealId,actor,EscrowStatus; номер счёта — только маска****NNNN, raw запрещён); metrics:crm_deal_escrow_provided_total{result}, latency p95 (< 500 мс, SLO команд); traces — OTel, correlation черезtraceparent; dashboardpin-crm; runbook —docs/06-services/crm/runbooks/(будущий путь по конвенции).
Обратные ссылки
Автоссылки: где используется этот документ.
- registries (1): Реестр API-методов PIN
Связанные документы
- Сущности: deal.md (дом полей
EscrowAccountNumber/EscrowStatus/BankId, enumEscrowStatus/DealContractType, инварианты INV-DEAL-7/9, маскирование), deal-timeline-event.md (EscrowProvided,TimelineEscrowPayload— только маска). - События: собственных публикуемых контрактов у операции нет; соседние переходы оформления сделки — deal-stage-changed.md, deal-completed.md.
- Контекст: README; tenancy — ADR-0052.