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

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 areainvestor
Feature groupdeals
Статусdraft
Документdocs/06-services/crm/api/investor/provide-escrow.md

Актор и доступ

ПроверкаЗначение
Actor typeUser (Инвестор — участник сделки)
Auth policy / Permissionscrm.deals.manage (record-level self-service инвестора; JWT identity)
Scope (RBAC)не применяется как tenant-scope: инвестор — вне tenant сделки (TenantId = DeveloperCompanyId)
Record-leveldeal.PrimaryInvestorUserId == actor (инвестор-владелец); чужая сделка → CRM_DEAL_NOT_FOUND
Tenant isolationtenant сделки = застройщик; из identity инвестора tenant не берётся, доступ — record-level по PrimaryInvestorUserId
Auditupdate (sensitive): EscrowAccountNumber, EscrowStatus, UpdatedByUserId; номер счёта в логи не пишется
Доступ

Выполнить операцию может только инвестор — владелец сделки (deal.PrimaryInvestorUserId == actor); tenant сделки (застройщик) в проверке не участвует — доступ полностью record-level. Чужая сделка возвращает 404 CRM_DEAL_NOT_FOUND, а не 403 — операция не раскрывает существование сделок других инвесторов.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/investor/crm/deals/{dealId}/escrow
Success status200 (обновлённая карточка сделки)
Idempotency headerне применяется (бизнес-идемпотентность по EscrowStatus+EscrowAccountNumber)
Correlationсквозной trace через заголовок traceparent
подсказка

Отдельного idempotency-ключа нет: бизнес-идемпотентность — по паре EscrowStatus+EscrowAccountNumber. Повторный вызов с тем же нормализованным номером при уже EscrowStatus = Provided — no-op, возвращается текущая карточка (200) без новой строки таймлайна и без доменного события.

Входные данные / параметры запроса

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
DealIdrouteGuidДа!= Guid.Empty; существующая сделка инвестора
EscrowAccountNumberbodystringДаtrim; 1..64; только цифры ^\d{1,64}$ (номер счёта)Sensitive: наружу маска ****NNNN; в логи не пишется
ActorUserIdidentity claimsGuidДаJWT инвестора; сверяется с deal.PrimaryInvestorUserId (record-level)пишется в UpdatedByUserId/таймлайн

Тело — { escrowAccountNumber }; DealId — из route, ActorUserId — из identity.

Модель ответа DealCardModel

Возвращается обновлённая карточка сделки (проекция /profile/deals/[id] инвестора). Дом полей — сущность DealEntity; ниже — только дельта этой операции.

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealEntity.IdИдентификатор сделки.
StatusDealStatusДаDealEntity.Status (enum строкой)Статус процесса без изменений (эскроу — атрибут, не переход статуса).
ContractTypeDealContractType?НетDealEntity.ContractType (enum строкой)Ddu/Dkp; эскроу применим только к Ddu.
BankIdGuid?НетDealEntity.BankIdБанк проведения (задаёт менеджер застройщика, US-INV-07); здесь read-only.
EscrowStatusEscrowStatusДаDealEntity.EscrowStatus (enum строкой)После операции — Provided («Эскроу забронирован»).
EscrowAccountNumberMaskedstring?Нетcomputed из DealEntity.EscrowAccountNumber****NNNN (последние 4)Маскированный номер для инвестора/агента. Полный номер наружу не отдаётся.
UpdatedAtDateTimeOffsetДаDealEntity.UpdatedAtUS-INV-04.

Полный EscrowAccountNumber доступен отдельной привилегированной проекцией только SellManager/PinManager (см. deal.md, «Безопасность»); этот инвесторский endpoint всегда отдаёт маску.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор (permission crm.deals.manage, иначе CRM_DEALS_FORBIDDEN); ActorUserId — из токена (tenant инвестора не участвует). Номер счёта нормализуется (убираются пробелы). Бизнес-идемпотентность: если у сделки уже EscrowStatus == Provided и хранимый номер совпадает с нормализованным — повтор; вернуть текущую карточку (200) без нового таймлайн/доменного события.
  2. Проверка входа и предусловий. DealId != Guid.Empty; EscrowAccountNumber не пустой после trim, 1..64, только цифры ^\d{1,64}$ — иначе VALIDATION_FAILED. Существование, record-level и допустимость состояния — по загруженной сделке ниже.
  3. Что читаем. Сделку инвестора под запись — по record-level предикату PrimaryInvestorUserId == ActorUserId и DeletedAt == null (scope-bypass developer-tenant, ADR-0052); нет совпадения → CRM_DEAL_NOT_FOUND (чужая сделка не раскрывается). Допустимость состояния по сделке: терминальные Completed/CancelledCRM_DEAL_ALREADY_TERMINAL (INV-DEAL-9); RosreestrRegisteredAt == null, иначе CRM_DEAL_ESCROW_LOCKED (после регистрации счёт менять нельзя); применимость ContractType != Dkp (null или Ddu), иначе CRM_DEAL_ESCROW_NOT_APPLICABLE (эскроу — только для ДДУ). Банковский вызов не выполняется (решение 9).
  4. Бизнес-правила и переходы. Проставить 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).
  5. Что меняется. В одном согласованном изменении (INV-TLE-2): обновляются поля эскроу на сделке + добавляется строка deal_timeline_events (append-only, INV-TLE-1). Интеграционных Kafka-событий нет — смена эскроу-статуса не покидает crm (нет downstream-контекста; статус объекта/банк не затрагиваются); строка таймлайна видна участникам сделки, включая менеджера застройщика (guard перед Росреестром — INV-DEAL-7), интеграционного пуша нет. Параллельная попытка изменить ту же сделку → CRM_DEAL_CONCURRENCY_CONFLICT (перечитать и повторить).
  6. Результат. Обновлённая карточка DealCardModel (эскроу-номер → маска ****NNNN), 200. Идемпотентный повтор (шаг 1) — 200 с текущей карточкой без нового события.

Побочные эффекты

ТипДетали
Integration eventsnone (доменная операция внутри crm; kafka-события не публикуются — эскроу-статус не покидает контекст)
External callsnone (банковская интеграция не реализуется — решение 9 ТЗ; номер счёта не верифицируется в банке)
Cache / projections / searchnone (проекция сделки не затрагивается — Title/ФИО/статус не меняются)
Notificationsопосредованно: таймлайн-строка EscrowProvided видна участникам сделки в карточке (менеджеру застройщика — для брони счёта в банке); отдельного интеграционного пуша нет

Ошибки и failure modes

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400DealId = Guid.Empty или номер не проходит ^\d{1,64}$«Проверьте номер эскроу-счёта»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
CRM_DEALS_FORBIDDEN403нет permission crm.deals.manage«Недостаточно прав»no retry
CRM_DEAL_NOT_FOUND404сделки нет или инвестор не её участник (record-level)«Сделка не найдена»no retry
CRM_DEAL_ESCROW_NOT_APPLICABLE409ContractType = Dkp (эскроу только для ДДУ)«Эскроу-счёт неприменим для этого договора»no retry
CRM_DEAL_ESCROW_LOCKED409RosreestrRegisteredAt != null (счёт уже зафиксирован при регистрации)«Изменение эскроу недоступно»no retry
CRM_DEAL_ALREADY_TERMINAL409сделка Completed/Cancelled (INV-DEAL-9)«Сделка завершена или отменена»no retry
CRM_DEAL_CONCURRENCY_CONFLICT409конкурентное изменение той же сделки«Повторите попытку»retry (перечитка)
Ошибки

Сделка другого инвестора возвращает 404 CRM_DEAL_NOT_FOUND, а не 403 — операция не раскрывает существование чужих сделок. Это тот же код, что и для реально несуществующей сделки.

Предусловия и постусловия

Предусловия (истинны до сценария):

  • инвестор — участник сделки (deal.PrimaryInvestorUserId = actor) с permission crm.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 с тем же номером. Тогда: 200 c текущей карточкой, новая строка таймлайна не добавляется, доменное/интеграционное событие не поднимается.
  • Договор ДКП. Дано: 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; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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

  • Сущности: deal.md (дом полей EscrowAccountNumber/EscrowStatus/BankId, enum EscrowStatus/DealContractType, инварианты INV-DEAL-7/9, маскирование), deal-timeline-event.md (EscrowProvided, TimelineEscrowPayload — только маска).
  • События: собственных публикуемых контрактов у операции нет; соседние переходы оформления сделки — deal-stage-changed.md, deal-completed.md.
  • Контекст: README; tenancy — ADR-0052.