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

Event Contract: pin.crm.booking-expired (BookingExpiredEvent)

Версионируемый контракт интеграционного события «бронирование истекло». Consumer-документы ссылаются сюда, не переописывая payload. Событие покрывает обе фазы истечения одного агрегата BookingEntity — истечение резерва (ReserveExpiresAt) и истечение брони (BookingExpiresAt); фаза различается полем Phase.

Назначение

Фиксирует терминальное истечение процесса бронирования, обнаруженное фоновой проверкой booking-expiration (INV-BKG-5): проверка переводит агрегат в ReserveExpired либо BookingExpired и тем же изменением публикует событие. Публичное: даёт downstream-контекстам факт освобождения объекта без синхронного запроса в crmchessboard возвращает объект недвижимости в «Свободен» (границы контекстов: CRM чужие таблицы не пишет, service-boundaries), messenger шлёт в чат сделки карточку «время истекло — подтвердите/продлите», notifications уведомляет участников.

Metadata

ПолеЗначение
Event typepin.crm.booking-expired
Topicpin.crm.booking-expired.v1
Partitions / key6 (ADR-0056, 1–10k активных броней) / key = BookingId (= AggregateId, порядок событий per-booking в топике)
ContractVersion1
Producercrm: фоновая проверка booking-expiration (INV-BKG-5)
Статусdraft

Событие публикуется атомарно с переходом статуса агрегата (actor = система/worker): пока истечение не зафиксировано, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once), ключ = BookingId.

Payload BookingExpiredEvent

Дом полей — entity: BookingEntity (раздел «Доменные и интеграционные события»); здесь перечислено только то, что реально в событии. PII событие не несёт (см. «Безопасность»).

ПолеТипОбязательностьИсточник (поле агрегата)CompatibilityОписание
BookingIdGuidДаBookingEntity.IdImmutableКлюч агрегата (= AggregateId = Kafka key).
TenantIdGuidДаBookingEntity.TenantId (= DeveloperCompanyId)ImmutableTenant boundary (застройщик объекта).
DealIdGuidДаBookingEntity.DealIdImmutableСделка-владелец процесса (deal.md); ключ карточки чата у messenger.
PropertyObjectIdGuidДаBookingEntity.PropertyObjectIdImmutableОбъект; chessboard по нему возвращает объект недвижимости в «Свободен».
Phasestring (enum BookingExpirationPhase)Давычисляется из перехода (Reserved/AwaitingPayment → ReserveExpiredReserve; Booked → BookingExpiredBooking)Additive only (новые значения)Какой таймер истёк — резерв или бронь.
ExpiredAtDateTimeOffsetДаBookingEntity.ExpiredAt (проставлен проверкой)ImmutableФактический момент истечения, зафиксированный проверкой.

Вложенный enum BookingExpirationPhase

ЗначениеУсловие фазыКарточка messenger
Reserveистёк ReserveExpiresAt (агрегат был в Reserved/AwaitingPayment)«время резерва истекло, подтвердите бронь»
Bookingистёк BookingExpiresAt (агрегат был в Booked, менеджер не успел Extend)«бронь истекла — подтвердите/продлите»

Поля клиента (ФИО/телефон), суммы оплаты, условия программы (ProgramTerms) в payload не входят: messenger берёт отображаемые данные из собственной реплики контакта по DealId/BookingId, chessboard и notifications — тем более. Событие несёт только идентификаторы перехода и его факт.

Когда публикуется

УсловиеПереход состоянияПубликация
now > ReserveExpiresAt при Status ∈ {Reserved, AwaitingPayment} (цикл проверки, INV-BKG-5)Reserved/AwaitingPayment → ReserveExpired; Phase = Reserveатомарно со сменой состояния агрегата
now > BookingExpiresAt при Status = Booked и менеджер не выполнил Extend до срабатывания проверкиBooked → BookingExpired; Phase = Bookingта же

Кандидаты отбираются по истёкшим срокам резерва/брони; истечение идемпотентно (ADR-0056: повторный цикл не находит уже переведённые). Отмена — отдельным событием booking-cancelled.md, а не этим.

Гонка условий

«Истечение по таймеру» и «подтверждение/продление менеджером» разрешаются оптимистичной блокировкой по версии агрегата: проигравший повторяет операцию с перечиткой, поэтому событие публикуется ровно для победившего терминального перехода.

Потребители (consumers)

Сверено с service-boundaries.md и таблицей событий booking.md.

Consumer-контекстSide effectsИдемпотентностьЗадержка (SLA)
chessboardОбъект недвижимости PropertyObjectId → «Свободен» (снятие резерва/брони; при Booking — гасит таймер объекта недвижимости)MessageId + idempotent set по PropertyObjectId (повторный «Свободен» — no-op)< 1 мин (объект недвижимости виден свободным)
messengerСистемная карточка в чат сделки DealId по Phase (резерв / бронь — текст выше, US-INV-16)MessageId< 5 мин
notificationsУведомление участникам сделки об истеченииMessageId< 5 мин

Кросс-топиковый порядок с booking-created/booking-confirmed (свои топики) key-ом не гарантируется: chessboard применяет статус объекта недвижимости идемпотентно (терминальный «Свободен» перекрывается только новым процессом брони — новый агрегат, новый BookingId).

Доставка, порядок и обработка сбоев

  • Гарантия публикации. Событие уходит только после фиксации перехода агрегата в ReserveExpired/ BookingExpired; при откате изменения событие не публикуется (нет «фантомных» истечений). Доставка — at-least-once, ключ BookingId.
  • Порядок. События одной брони упорядочены ключом BookingId (одна партиция). Кросс-топиковый порядок с booking-created/booking-confirmed/booking-cancelled не гарантируется — chessboard применяет терминальный «Свободен» идемпотентно (перекрывается только новым процессом брони — новый BookingId).
  • Идемпотентность потребителя. Дедуп по MessageId; chessboard дополнительно — idempotent set по PropertyObjectId (повторное «Свободен» — no-op).
  • Retry. Транзиентный сбой потребителя (недоступность его хранилища/сети) → повтор обработки с backoff; событие остаётся доступным до успешной обработки, side effect (освобождение объекта / карточка) не теряется.
  • DLQ. Неустранимый сбой (сообщение не разбирается схемой, нарушение инварианта проекции) после исчерпания попыток → dead-letter c алертом оператору; повторное проигрывание из DLQ безопасно (обработка идемпотентна). Для chessboard освобождение объекта — fail-safe: применяется даже при неизвестной Phase.

Версионирование и совместимость

  • ContractVersion = 1; при несовпадении версии контракта потребитель логирует предупреждение и продолжает обработку. Топик кодирует версию суффиксом .v1 по конвенции.
  • Additive-only для minor: новые поля добавляются как optional с безопасным default — без bump.
  • Breaking (удаление/переименование/смена типа или смысла) — ContractVersion = 2 + топик pin.crm.booking-expired.v2 + dual-publish на период миграции + deadline миграции consumers + sunset .v1.
  • Unknown Phase — fallback: chessboard при неизвестной фазе всё равно освобождает объект недвижимости (fail-safe для освобождения), messenger/notifications логируют warning и пропускают карточку; строка сохраняется как есть.

Безопасность

ПолеКласс данныхПравило
BookingId, TenantId, DealId, PropertyObjectIdInternalinclude
Phase, ExpiredAtInternalinclude
Без PII

Событие не содержит PII: телефоны/ФИО клиента, суммы, реквизиты (RequisiteSetId), условия программы в payload отсутствуют — только идентификаторы (Guid), enum-фаза и timestamp. Consumer, которому нужны отображаемые ФИО/телефон (карточка messenger), берёт их из собственной реплики, наполняемой из pin.identity.users.v1, а не из этого события; при попадании Guid/идентификаторов в логи маскирование не требуется (не PII). Событие НЕ содержит secrets, tokens, raw credentials, платёжных реквизитов, unbounded вложений.

Наблюдаемость

  • Логи (без PII): факт публикации booking-expired с BookingId/DealId/PropertyObjectId/Phase/ ExpiredAt (идентификаторы, не PII); на стороне потребителя — результат обработки (объект освобождён / карточка отправлена / уведомление доставлено).
  • Метрики: число публикаций по топику в разрезе Phase; сквозная задержка доставки до chessboard (порог < 1 мин — освобождение объекта видно свободным), до messenger/notifications (< 5 мин); глубина DLQ топика (порог алерта > 0); доля повторных доставок (индикатор проблем идемпотентности потребителя).
  • Доступ: топик закрыт m2m ACL Kafka (только chessboard/messenger/notifications); PII в событие не входит — маскирование payload не требуется.

Acceptance Criteria

  • Given бронь в Reserved/AwaitingPayment при now > ReserveExpiresAt, When отрабатывает проверка booking-expiration, Then агрегат переходит в ReserveExpired, и ровно одно booking-expired публикуется атомарно с переходом, Phase = Reserve, ExpiredAt проставлен.
  • Given бронь в Booked при now > BookingExpiresAt и менеджер не выполнил Extend, When отрабатывает проверка, Then переход Booked → BookingExpired, событие с Phase = Booking.
  • Given менеджер подтвердил/продлил бронь раньше срабатывания проверки, When цикл доходит до этой брони, Then истечение не применяется и событие не публикуется (проигравший по версии повторяет операцию с перечиткой).
  • Given уже истёкшая бронь, When повторный цикл проверки, Then повторного события нет (идемпотентность истечения — ADR-0056).
  • Given повторная доставка события в chessboard, When объект уже Свободен, Then переход — no-op (объект освобождён ровно один раз).
  • Given потребитель получил неизвестную Phase, When обрабатывает, Then chessboard всё равно освобождает объект (fail-safe), messenger/notifications логируют warning и пропускают карточку.
  • Given breaking-изменение payload, When вводится новый контракт, Then публикуется pin.crm.booking-expired.v2 c dual-publish и sunset .v1 (потребители .v1 не ломаются).

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

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

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

  • Сущность и переходы (INV-BKG-5, stateDiagram): booking.md; условия таймеров — booking-program.md; родительский процесс — deal.md.
  • Соседние контракты брони: booking-created.md, booking-confirmed.md, booking-cancelled.md, booking-paid.md.
  • Producer (проверка): раздел «Background jobs» crm/README.md; статус объекта недвижимости — консюмер docs/06-services/chessboard/ (будущий путь по конвенции).
  • ADR-0056 (polling-проверка без TTL, idempotent); две точки входа брони.