Event Contract: pin.crm.booking-expired (BookingExpiredEvent)
Версионируемый контракт интеграционного события «бронирование истекло». Consumer-документы ссылаются сюда, не
переописывая payload. Событие покрывает обе фазы истечения одного агрегата
BookingEntity — истечение резерва (ReserveExpiresAt) и истечение брони
(BookingExpiresAt); фаза различается полем Phase.
Назначение
Фиксирует терминальное истечение процесса бронирования, обнаруженное фоновой проверкой booking-expiration
(INV-BKG-5): проверка переводит агрегат в ReserveExpired либо BookingExpired и тем же изменением
публикует событие. Публичное: даёт downstream-контекстам факт освобождения объекта без синхронного запроса в
crm — chessboard возвращает объект недвижимости в «Свободен» (границы контекстов: CRM чужие таблицы не пишет,
service-boundaries), messenger шлёт в чат сделки карточку
«время истекло — подтвердите/продлите», notifications уведомляет участников.
Metadata
| Поле | Значение |
|---|---|
| Event type | pin.crm.booking-expired |
| Topic | pin.crm.booking-expired.v1 |
| Partitions / key | 6 (ADR-0056, 1–10k активных броней) / key = BookingId (= AggregateId, порядок событий per-booking в топике) |
| ContractVersion | 1 |
| Producer | crm: фоновая проверка booking-expiration (INV-BKG-5) |
| Статус | draft |
Событие публикуется атомарно с переходом статуса агрегата (actor = система/worker): пока истечение не
зафиксировано, событие не отправляется; после фиксации — доставляется гарантированно (at-least-once), ключ =
BookingId.
Payload BookingExpiredEvent
Дом полей — entity: BookingEntity (раздел «Доменные и интеграционные события»);
здесь перечислено только то, что реально в событии. PII событие не несёт (см. «Безопасность»).
| Поле | Тип | Обязательность | Источник (поле агрегата) | Compatibility | Описание |
|---|---|---|---|---|---|
BookingId | Guid | Да | BookingEntity.Id | Immutable | Ключ агрегата (= AggregateId = Kafka key). |
TenantId | Guid | Да | BookingEntity.TenantId (= DeveloperCompanyId) | Immutable | Tenant boundary (застройщик объекта). |
DealId | Guid | Да | BookingEntity.DealId | Immutable | Сделка-владелец процесса (deal.md); ключ карточки чата у messenger. |
PropertyObjectId | Guid | Да | BookingEntity.PropertyObjectId | Immutable | Объект; chessboard по нему возвращает объект недвижимости в «Свободен». |
Phase | string (enum BookingExpirationPhase) | Да | вычисляется из перехода (Reserved/AwaitingPayment → ReserveExpired ⇒ Reserve; Booked → BookingExpired ⇒ Booking) | Additive only (новые значения) | Какой таймер истёк — резерв или бронь. |
ExpiredAt | DateTimeOffset | Да | 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, PropertyObjectId | Internal | include |
Phase, ExpiredAt | Internal | include |
Событие не содержит 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 обрабатывает, Thenchessboardвсё равно освобождает объект (fail-safe),messenger/notificationsлогируют warning и пропускают карточку. - Given breaking-изменение payload, When вводится новый контракт, Then публикуется
pin.crm.booking-expired.v2c dual-publish и sunset.v1(потребители.v1не ломаются).
Обратные ссылки
Автоссылки: где используется этот документ.
- Домен (CQRS) (1): Entity / Aggregate: CalendarEventEntity
- API (3): GET /api/investor/crm/deals/{dealId}/timeline — Таймлайн сделки (инвестор), POST /api/agent/crm/bookings/{bookingId}/confirm — Подтвердить бронь (бесплатная программа), POST /api/developer/crm/bookings/{bookingId}/extend — Продлить бронь
- Use Cases (3): UC-BKG-002. Бронирование Резерв → Бронь, UC-BKG-003. Истечение резерва/брони и продление, UC-CAL-007. Дедлайны брони CRM в календаре агента
- События (4): Event Contract: pin.crm.booking-cancelled (BookingCancelledEvent), Event Contract: pin.crm.booking-confirmed (BookingConfirmedEvent), Event Contract: pin.crm.booking-created (BookingCreatedEvent), Event Contract: pin.crm.booking-paid (BookingPaidEvent)
- Консюмеры (2): Consumer: Booking Expired Card (MessengerBookingExpiredConsumer), Consumer: Calendar CRM Booking Sync (calendar-crm-booking-events)
- Фоновые задачи (3): Background Job: BookingExpirationJob, Background Job: property-object-hold-expiration (страховка от потерянных release-событий CRM), Background Job: TimelineOverdueJob (worker timeline-overdue)
- Саги (1): Saga «Booking» (Резерв → Бронь → передача в продажу)
- index.md (1): Calendar Service
- registries (1): Реестр событий PIN
Связанные документы
- Сущность и переходы (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); две точки входа брони.