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

POST /api/investor/crm/bookings/{bookingId}/payment-proof — Прикрепить чек оплаты брони

Инвестор оплатил платное бронирование вне платформы и прикрепляет чек (US-INV-21). Онлайн-эквайринг отложен (решение 9 ТЗ): онлайн-оплаты нет, вместо неё — загрузка файла-чека, который затем вручную подтверждает менеджер застройщика (confirm-booking-payment.md). Операция создаёт документ сделки типа BookingPaymentProof в статусе Uploaded, привязанный к брони; сама бронь не мутируется (PaymentStatus остаётся Pending — ждёт подтверждения). Дом полей — deal-document.md и booking.md.

Назначение

Принять от инвестора {BookingId, FileId, FileName, ContentType, SizeBytes} и завести документ DealDocumentEntity (Type = BookingPaymentProof, Status = Uploaded, с заполненным BookingId) — прямая загрузка [*] → Uploaded (INV-DOC-1: загрузчик типа BookingPaymentProof — инвестор). Владелец — контекст crm, feature group booking, сценарий US-INV-21.

Инвариант

INV-DOC-1: загрузчик документа BookingPaymentProof — только инвестор (PrimaryInvestorUserId берётся из токена, а не из тела запроса). Операция не мутирует бронь: PaymentStatus остаётся Pending до ручного подтверждения менеджером застройщика (confirm-booking-payment.md) — здесь публикуется только DocumentStatusChangedEvent, BookingPaidEvent не публикуется.

Metadata

ПолеЗначение
Сервис/контекстcrm
API areainvestor
Feature groupbooking
Статусdraft
Документdocs/06-services/crm/api/investor/attach-booking-payment-proof.md

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

ПроверкаЗначение
Actor typeUser (Инвестор)
Auth policy / Permissionsauthenticated (JWT инвестора) + crm.bookings.manage; self-service по своей брони
Scope (RBAC)не применяется: инвестор — вне developer-tenant (кросс-tenant участник, ADR-0052)
Record-leveldeal.PrimaryInvestorUserId = actor.UserId (бронь → сделка); форсится сервером, чужую бронь прикрепить нельзя
Tenant isolationне developer-tenanted: document.TenantId = booking.TenantId = DeveloperCompanyId наследуется от сделки; изоляция запроса — record-level по PrimaryInvestorUserId из claims
Auditcreate (DealDocumentEntity); UploadedByUserId/CreatedByUserId = actor.UserId; бинарь чека, FileId, FileName в логи не пишутся
Доступ

Record-level предикат deal.PrimaryInvestorUserId = actor.UserId форсится сервером из JWT — инвестор не может подставить чужой PrimaryInvestorUserId через тело запроса. Инвестор — кросс-tenant участник вне developer-tenant (ADR-0052): RBAC scope не применяется, доступ к чужой брони закрыт именно record-level проверкой, а не tenant-фильтром.

HTTP-контракт

ПолеЗначение
MethodPOST
Route/api/investor/crm/bookings/{bookingId}/payment-proof
Success status201 (документ-чек создан)
Idempotency headerне применяется (повтор → новая запись документа, INV-DOC-4; менеджер выбирает актуальный чек при подтверждении)
Correlationсквозной trace через заголовок traceparent

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

ПолеИсточникТипОбязательностьВалидация / ОграниченияБезопасность
BookingIdrouteGuidДа!= Guid.Empty; существующая бронь; deal.PrimaryInvestorUserId = actor; Status = AwaitingPayment; ProgramTerms.IsPaid; PaymentStatus = Pending
FileIdbodyGuidДа!= Guid.Empty; логическая ссылка на storageInternal: в логи не пишется
FileNamebodystringДа1..512, не пустое после trimможет нести детали — целиком не логируется
ContentTypebodystringДа{application/pdf, image/jpeg, image/png} (US-INV-12: JPG/PDF; конфигурация)
SizeBytesbodylongДа> 0 и <= 52_428_800 (50 МБ, конфигурация)защита от unbounded
PrimaryInvestorUserIdidentity claimsGuidДаRequired; record-level предикат, форсится = actor.UserIdscope isolation

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

Проекция созданной записи (дельта дома deal-document.md); PII/бинарь не содержит — только метаданные и FileId (скачивание — по пресайн-ссылке storage с проверкой участия).

ПолеТипОбязательностьИсточникMaskingОписание
IdGuidДаDealDocumentEntity.IddocumentId чека (на него ссылается подтверждение оплаты).
DealIdGuidДаDealIdСделка-владелец (deal.md).
BookingIdGuidДаBookingIdБронь, к которой прикреплён чек.
Typestring (enum DealDocumentType)ДаTypeВсегда BookingPaymentProof.
Statusstring (enum DealDocumentStatus)ДаStatusПосле операции — Uploaded.
TitlestringДаTitle«Чек об оплате бронирования».
FileIdGuidДаFileIdФайл в storage/CDN.
FileNamestringДаFileNameОригинальное имя файла.
ContentTypestringДаContentTypeMIME-тип чека.
SizeByteslongДаSizeBytesРазмер файла.
UploadedAtDateTimeOffsetДаUploadedAtМомент загрузки (US-INV-13).
CreatedAtDateTimeOffsetДаCreatedAtСоздание записи.

Алгоритм

  1. Контекст и доступ. Операцию выполняет инвестор; PrimaryInvestorUserId берётся из его токена (из тела не принимается). Повтор загрузки допустим — каждый раз создаётся новая запись документа (INV-DOC-4).
  2. Проверка входа и предусловий.
    • формат: BookingId/FileId не пустые; FileName 1..512; ContentType ∈ {application/pdf, image/jpeg, image/png}; SizeBytes > 0 && <= 52_428_800 — иначе VALIDATION_FAILED;
    • существование брони: если брони нет → CRM_BOOKING_NOT_FOUND;
    • принадлежность/record-level (INV-DOC-1, загрузчик — инвестор): deal.PrimaryInvestorUserId == PrimaryInvestorUserId — иначе CRM_BOOKING_FORBIDDEN;
    • терминальность сделки (INV-DOC-5): deal.Status ∉ {Completed, Cancelled} — иначе CRM_DEAL_ALREADY_TERMINAL;
    • допустимость состояния брони: booking.Status == AwaitingPayment (договор брони уже загружен застройщиком) — иначе CRM_BOOKING_INVALID_STATE;
    • применимость оплаты: booking.ProgramTerms.IsPaid == true && booking.PaymentStatus == Pending — иначе CRM_BOOKING_PAYMENT_NOT_REQUIRED (бесплатная программа / уже оплачено).
  3. Что читаем. Бронь и её сделку (родитель для record-level и guard терминальности) — только для проверок выше; ни бронь, ни сделка этой операцией не изменяются. Существующий чек не требуется — это проактивная загрузка [*] → Uploaded.
  4. Бизнес-правила и переходы. Создаётся документ (INV-DOC-1/INV-DOC-3): TenantId = booking.TenantId (= DeveloperCompanyId); DealId = booking.DealId; BookingId = booking.Id; Type = BookingPaymentProof; Status = Uploaded; Title = "Чек об оплате бронирования"; FileId/FileName/ContentType/SizeBytes — из входа (INV-DOC-3: обязательны со статуса Uploaded, тип/размер в допустимых пределах); UploadedByUserId = CreatedByUserId = actor.UserId; UploadedAt = CreatedAt = now. Добавляется строка таймлайна сделки «чек об оплате прикреплён» (deal-timeline-event.md); парная таймлайн-строка BookingPaid появится позднее при подтверждении менеджером (confirm-booking-payment.md), не здесь. Бронь не изменяется: PaymentStatus остаётся Pending (переход → Paid — только при подтверждении, INV-BKG-4).
  5. Что меняется. В одном согласованном изменении (INV-DOC-6): добавляется запись документа + строка таймлайна; бронь/сделка не обновляются.
  6. Какие факты фиксируются. Публикуется DocumentStatusChangedEvent → топик pin.crm.document-status-changed.v1 (ContractVersion = 1, AggregateType = "DealDocument") с payload {DocumentId, DealId, TenantId, DocumentType = BookingPaymentProof, FromStatus, ToStatus = Uploaded, ChangedByUserId = actor.UserId, ChangedAt = UploadedAt} — потребитель notifications (уведомление о статусе документа, US-INV-14.3); контракт — document-status-changed.md (FromStatus = Requested при наличии запроса-предшественника, иначе прямой upload [*] → Uploaded). BookingPaidEvent здесь не публикуется — только после подтверждения менеджером (booking-paid.md).
  7. Результат. Проекция созданного документа DealDocumentModel (Type = BookingPaymentProof, Status = Uploaded, FileId, UploadedAt, …) → HTTP 201.

Диаграмма

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

ТипДетали
Integration eventsDocumentStatusChangedEventpin.crm.document-status-changed.v1 (потребитель notifications). BookingPaidEventне здесь (только при подтверждении менеджером)
External callsnone (бинарь чека уже в storage/CDN — загрузка клиентом отдельным пресайн-flow; в CRM пишутся только метаданные и FileId)
Cache / projections / searchnone
Notificationsопосредованно: notifications — потребитель document-status-changed (статус документа инвестору/менеджеру, US-INV-14.3)

Ошибки и failure modes

Ошибки

Два отказа не сводятся к простой валидации формата: CRM_BOOKING_INVALID_STATE — договор брони ещё не загружен застройщиком (Status != AwaitingPayment) либо бронь уже завершена; CRM_BOOKING_PAYMENT_NOT_REQUIRED — программа бесплатная (IsPaid = false) либо оплата уже подтверждена (PaymentStatus != Pending), прикреплять чек не нужно.

КодHTTP statusУсловиеСообщениеRetry
VALIDATION_FAILED400пустой BookingId/FileId, недопустимый ContentType, размер вне пределов, пустое FileName (INV-DOC-3)«Проверьте файл чека (тип JPG/PDF, размер до 50 МБ)»no retry
UNAUTHORIZED401нет/просрочен JWT«Требуется авторизация»no retry
CRM_BOOKING_NOT_FOUND404бронь не найдена«Бронирование не найдено»no retry
CRM_BOOKING_FORBIDDEN403deal.PrimaryInvestorUserId != actor (чужая бронь)«Нет доступа к бронированию»no retry
CRM_BOOKING_INVALID_STATE409Status != AwaitingPayment (договор брони ещё не загружен / бронь завершена)«Чек можно прикрепить только после загрузки договора бронирования»no retry (перечитать состояние)
CRM_BOOKING_PAYMENT_NOT_REQUIRED409бесплатная программа (IsPaid = false) или PaymentStatus != Pending (уже оплачено)«Оплата по этой брони не требуется»no retry
CRM_DEAL_ALREADY_TERMINAL409сделка в терминальном статусе (INV-DOC-5)«Сделка завершена или отменена»no retry

Acceptance Criteria

  • Given своя бронь AwaitingPayment, ProgramTerms.IsPaid = true, PaymentStatus = Pending, When инвестор загружает валидный чек (PDF/JPEG/PNG), Then создан документ BookingPaymentProof (Uploaded) с заполненным BookingId, добавлена строка таймлайна, опубликовано DocumentStatusChangedEventpin.crm.document-status-changed.v1 (ToStatus = Uploaded); бронь не мутируется (PaymentStatus остаётся Pending); ответ 201.
  • Given чек уже прикреплён, When повторная загрузка, Then создаётся новая запись документа (INV-DOC-4); менеджер выбирает актуальный чек при подтверждении.
  • Given deal.PrimaryInvestorUserId != actor, When attach, Then CRM_BOOKING_FORBIDDEN (403) по INV-DOC-1; брони нет → CRM_BOOKING_NOT_FOUND (404).
  • Given Status != AwaitingPayment (договор брони ещё не загружен либо бронь завершена), When attach, Then CRM_BOOKING_INVALID_STATE (409).
  • Given бесплатная программа (IsPaid = false) либо PaymentStatus != Pending, When attach, Then CRM_BOOKING_PAYMENT_NOT_REQUIRED (409).
  • Given ContentType ∉ {application/pdf, image/jpeg, image/png}, размер вне (0, 50 МБ] или пустые BookingId/FileId, When attach, Then VALIDATION_FAILED (400) по INV-DOC-3.
  • Given чек прикреплён, When проверяется эффект на бронь, Then BookingPaidEvent не публикуется и переход PaymentStatus → Paid не происходит — только после ручного подтверждения менеджером (INV-BKG-4, confirm-booking-payment.md); парная строка таймлайна BookingPaid появляется тогда же, не здесь.
  • Given сделка Completed/Cancelled, When attach, Then CRM_DEAL_ALREADY_TERMINAL (409) по INV-DOC-5.

Совместимость и наблюдаемость

  • Новые optional поля DealDocumentModel и payload события — additive без breaking change; удаление/ переименование/смена типа поля или значения enum (DealDocumentType/DealDocumentStatus) — breaking (migration + rollout; версия события — новый .v2 + dual-publish). Список допустимых ContentType — конфигурация (расширение — additive).
  • Logs: booking-payment-proof attached (BookingId, DealId, DocumentId, PrimaryInvestorUserId; FileId/ FileName/бинарь — не пишутся); metrics: crm_booking_payment_proofs_total{result}, latency p95 (SLO команд < 500 мс); traces — OTel, correlation через traceparent; dashboard pin-crm; runbook — docs/06-services/crm/runbooks/ (будущий путь по конвенции).

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

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

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