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

Статусная машина бронирования

Версия: 1.0 Дата: 26.04.2026 Статус: Готов к обсуждению

Назначение документа

Документ определяет полную статусную машину каноничной сущности Booking платформы Vitiana — все состояния, все допустимые переходы, инварианты на каждое состояние, правила идемпотентности, обработку неизвестного внешнего состояния (unknown external state), восстановление после сбоя, terminal states, и интеграцию с платёжным контуром, контуром поставщиков и контуром взаиморасчётов.

Включает:

  • Полный список 14 каноничных состояний (расширение тезисного списка из API Contracts).
  • Граф допустимых переходов с триггерами и инвариантами.
  • Правила идемпотентности для каждой операции.
  • Различение состояний supplier_confirmed и platform_confirmed (зафиксировано в Семантика предложений, цены, бронирования, детализируется здесь).
  • Обработку состояния unknown_external_state — ключевая операционная категория для устойчивости.
  • Replay-safe механизмы — восстановление состояния через журнал событий.
  • Терминальные состояния (terminal states) и пост-обработку.
  • Связь со state machine PaymentIntent (см. Платёжный домен).
  • Связь с сагой пакетного тура (см. Операционная модель конструктора туров).
  • Каноничные таймауты и правила перехода по таймауту.
  • Запрещённые переходы и защита от них.
  • Метрики и наблюдаемость.
  • Фазы развёртывания.

Документ читается после:

В корневых документах зафиксированы тезисные списки состояний в разных контекстах. Этот документ синтезирует их в единую state machine с явными переходами, инвариантами и правилами устойчивости.

Бронирование — самый критичный финансовый контур платформы. Ошибка в state machine — это либо двойное списание клиента, либо потерянное бронирование, либо рассогласование с поставщиком, либо невыплата поставщику. Всё это — реальные деньги и реальная ответственность.

Реальное состояние реализации (home-to-go-api): рабочий Stuba booking module существует, но без полноценной state machine. Текущее ведение состояний — линейное и упрощённое. Целевое состояние — то, что определяется этим документом.

Правило 00000 (платформа главенствует над поставщиками) применяется здесь напрямую: каноничная state machine Bookingмодель платформы, не отражение состояний поставщиков. Поставщик возвращает свои собственные коды состояний — слой приёма данных переводит их в каноничные.

Главное решение

Booking — каноничная state machine платформы с 14 состояниями, явными переходами, инвариантами, идемпотентностью и replay-safe журналом, не «черный ящик с упрощёнными состояниями».

Это означает:

  • 14 каноничных состояний — каждое имеет точное определение и место в графе.
  • Все переходы зарегистрированы через BookingStateTransition сущность с журналом для аудита.
  • Идемпотентность всех критических операций через idempotency_keycreate, confirm, cancel, amend, complete.
  • Различение supplier_confirmed и platform_confirmed — два разных момента; платформа фиксирует свои инварианты после ответа поставщика.
  • unknown_external_state — first-class категория — когда поставщик не отвечает в разумное время или отвечает некорректно. Не «потерянное бронирование», а явное состояние с восстановлением.
  • Replay-safe — состояние можно восстановить из журнала событий после сбоя любого компонента.
  • Каноничные таймауты для каждого ожидающего состояния — никаких бесконечных ожиданий.
  • Terminal states — после cancelled, failed, completed бронирование переходит в архивное состояние и не меняется.
  • Защита от запрещённых переходов — попытка перевести cancelled обратно в confirmed блокируется на уровне state machine.

Каноничные сущности (углубление существующих)

Уже зафиксированные сущности

В API Contracts и Семантика предложений, цены, бронирования уже зафиксирована сущность Booking с базовыми полями. Этот документ расширяет её операционными подсущностями.

Новые операционные сущности

BookingStateTransition — переход между состояниями

Сущность, представляющая факт перехода конкретного бронирования из одного состояния в другое.

{
transition_id: UUID,
booking_id: UUID,
from_state: enum,
to_state: enum,
trigger: enum,
triggered_by: object,
idempotency_key: string,
preconditions_satisfied: array,
invariants_validated: array,
caused_by_event_id: UUID,
caused_by_supplier_response: object,
saga_step_id: UUID,
applied_at: timestamp
}

Поля:

  • trigger — что инициировало переход (user_action / partner_api_call / supplier_callback / timeout / restoration_job / saga_step / compensating_action / manual_override).
  • triggered_by — детальный контекст триггера (кто, какая операция, какие параметры).
  • idempotency_key — ключ, обеспечивающий идемпотентность: повтор той же операции с тем же ключом не создаёт второго перехода.
  • preconditions_satisfied — какие предусловия были проверены и удовлетворены.
  • invariants_validated — какие инварианты были сохранены при переходе.
  • caused_by_event_id — если переход вызван доменным событием.
  • caused_by_supplier_response — снимок ответа поставщика (для аудита).
  • saga_step_id — связь с шагом саги пакетного тура (если применимо).

BookingInvariantCheck — проверка инварианта

Сущность, представляющая формальную проверку инварианта на бронировании.

{
check_id: UUID,
booking_id: UUID,
invariant_class: enum,
check_logic: object,
result: enum,
detected_at: timestamp,
resolved_at: timestamp,
resolution: enum,
severity: enum,
notes: string
}

Поля:

  • invariant_class — класс инварианта (payment_state_consistent / supplier_state_known / quote_not_expired / mor_assigned / tenant_active / compliance_satisfied).
  • severity — серьёзность нарушения (error — блокирует переход; warning — допускает с пометкой; info — для журнала).

BookingRestorationJob — задача восстановления

Сущность, представляющая операцию восстановления бронирования из неопределённого состояния.

{
restoration_id: UUID,
booking_id: UUID,
restoration_class: enum,
triggered_at: timestamp,
completed_at: timestamp,
state_before_restoration: enum,
state_after_restoration: enum,
actions_taken: array,
supplier_query_results: array,
outcome: enum,
next_retry_at: timestamp,
retry_count: integer
}

Поля:

  • restoration_class — класс задачи (unknown_state_recovery — выяснение реального состояния у поставщика; timeout_triggered_check — плановая проверка по таймауту; manual_investigation — запрос ручного расследования; replay_from_event_log — пересборка состояния из журнала событий).
  • outcome — итог (state_clarified / still_unknown_retry_scheduled / escalated_to_operator / terminal_resolution).
  • retry_count — счётчик попыток (после максимума — эскалация).

Связь с другими каноничными сущностями

Полный список 14 состояний

draft

submitted

pending_revalidation

pending_supplier_confirmation

├──→ supplier_confirmed
│ ↓
│ platform_confirmed (≡ confirmed для внешних потребителей)
│ ↓
│ completed

├──→ partially_confirmed

├──→ unknown_external_state
│ ↓ (после восстановления)
│ возврат в один из других состояний

└──→ failed

(параллельный путь отмены — со многих состояний:)

cancel_requested

cancel_in_progress_supplier

cancelled

(параллельный путь изменения — после confirmed:)

amendment_in_progress

├──→ confirmed (после успеха)
└──→ amendment_failed (откат к prior state)

Описание каждого состояния

1. draft — черновик

Бронирование создано, но ещё не отправлено на обработку. Минимально заполненная сущность с привязкой к Tenant, Quote (или его кандидатам), client_context.

Инварианты:

  • Tenant активен.
  • Хотя бы один связанный Quote существует (опционально для черновиков, формирующихся итеративно).

Допустимые переходы:

  • submitted (пользователь отправил черновик).
  • cancelled (пользователь удалил черновик).

2. submitted — отправлено

Бронирование отправлено в обработку. Платформа начинает выполнение pre-checks.

Инварианты:

  • Quote действителен (не истёк).
  • Tenant активен и не превысил лимит тарифа.
  • Платёжное намерение в состоянии created или awaiting_method.

Допустимые переходы:

  • pending_revalidation (требуется проверка актуальности предложения у поставщика).
  • pending_supplier_confirmation (если revalidation не нужна — пропуск).
  • failed (предусловия не выполнены).
  • cancel_requested (пользователь отменил до обработки).

3. pending_revalidation — ожидание повторной проверки

Платформа проверяет актуальность предложения у поставщика перед фиксацией. Это нужно для drift detection на момент бронирования (цена/доступность могла измениться с момента создания Quote).

Инварианты:

  • Запрос к поставщику отправлен.
  • Срок ожидания (timeout) активен.

Допустимые переходы:

  • pending_supplier_confirmation (предложение актуально, переходим к фиксации).
  • failed (предложение устарело, повторное бронирование требует нового Quote).
  • unknown_external_state (поставщик не ответил в срок).
  • cancel_requested (пользователь отменил во время ожидания).

4. pending_supplier_confirmation — ожидание подтверждения поставщика

Запрос на бронирование отправлен поставщику; платформа ждёт ответа. Это самое распространённое промежуточное состояние — типично длится секунды-минуты, для асинхронных поставщиков может длиться часы.

Инварианты:

  • PaymentIntent в состоянии authorized (платёж зарезервирован).
  • Запрос к поставщику отправлен с уникальным supplier_request_id.
  • Срок ожидания активен.

Допустимые переходы:

  • supplier_confirmed (поставщик подтвердил).
  • partially_confirmed (поставщик подтвердил часть).
  • failed (поставщик отклонил).
  • unknown_external_state (поставщик не ответил в срок).
  • cancel_requested (отмена в момент ожидания — сложный случай, см. ниже).

5. supplier_confirmed — поставщик подтвердил

Поставщик дал положительный ответ. Бронирование у поставщика существует, но платформа ещё не зафиксировала свои внутренние инварианты (settlement, фиксация платежа, обновление аналитики).

Инварианты:

  • Поставщик вернул confirmation_id и положительное состояние.
  • Поле supplier_confirmation_data заполнено.

Допустимые переходы:

  • platform_confirmed (платформа завершила свои инварианты — единственный нормальный переход).
  • unknown_external_state (если последующие операции платформы провалились и состояние стало неопределённым).

Этот этап критичен: платформа уже должна клиенту услугу, но ещё не зафиксировала это во всех контурах. Запрещён прямой переход в failed или cancelled — отмена возможна только через стандартный поток отмены после platform_confirmed.

6. platform_confirmed (синоним confirmed для внешних потребителей) — платформа подтвердила

Все платформенные инварианты выполнены: платёж зафиксирован (PaymentIntent.captured), создан Settlement для взаиморасчёта с поставщиком, опубликовано доменное событие booking.confirmed, начата пост-обработка.

Инварианты:

Допустимые переходы:

  • completed (бронирование исполнено: дата выезда прошла).
  • cancel_requested (клиент или поставщик инициировал отмену).
  • amendment_in_progress (запрошено изменение — даты, состав, и т.д.).

Внешний вид: для всех внешних поверхностей и для партнёров это состояние называется просто confirmed. Внутреннее различение supplier_confirmed / platform_confirmed нужно только для саги и операционных команд.

7. partially_confirmed — частичное подтверждение

Поставщик подтвердил часть запрошенного (например, 2 из 3 номеров). Это состояние требующее решения — клиент должен явно согласиться на частичное или отменить.

Инварианты:

  • Поставщик вернул частичное состояние.
  • Платформа уведомила клиента и ожидает решения.
  • Срок ожидания решения активен.

Допустимые переходы:

  • platform_confirmed (клиент согласился на частичное; платежу делается частичная фиксация).
  • cancel_requested (клиент не согласен; полная отмена с возвратом).
  • unknown_external_state (если последующие операции провалились).

8. unknown_external_state — неопределённое внешнее состояние

Самое важное операционное состояние — поставщик не отвечает или ответил некорректно. Платформа не знает, существует ли бронирование у поставщика.

Это не «потерянное бронирование», а явное состояние с обязательным восстановлением.

Инварианты:

  • Создан BookingRestorationJob со статусом unknown_state_recovery.
  • Срок повторной попытки настроен.
  • PaymentIntent остаётся в authorized (не фиксируется до выяснения).

Допустимые переходы (после восстановления):

  • pending_supplier_confirmation (можно повторить попытку безопасно).
  • supplier_confirmed (восстановление выяснило, что бронирование существует).
  • failed (восстановление выяснило, что бронирования нет).
  • cancel_requested (по решению оператора).

См. секцию «Обработка unknown_external_state» ниже.

9. failed — провал

Терминальное состояние неудачного бронирования. Бронирование не существует (ни у поставщика, ни на платформе с финансовыми обязательствами).

Инварианты:

  • PaymentIntent.state = voided (платёж снят без списания) или возврат в случае состоявшегося списания.
  • Событие booking.failed опубликовано.
  • Причина провала зафиксирована в failure_reason.

Допустимые переходы: нет (terminal state). Новое бронирование на ту же сущность создаётся как отдельный Booking с собственным жизненным циклом.

10. cancel_requested — запрошена отмена

Кто-то (клиент, агент, поставщик, платформа) запросил отмену. Платформа начинает обработку.

Инварианты:

Допустимые переходы:

  • cancel_in_progress_supplier (отмена отправлена поставщику).
  • cancelled (если отмена не требует обращения к поставщику — например, до supplier_confirmed).

11. cancel_in_progress_supplier — отмена выполняется у поставщика

Запрос на отмену отправлен поставщику; платформа ждёт подтверждения отмены.

Инварианты:

  • Запрос к поставщику отправлен.
  • Срок ожидания активен.

Допустимые переходы:

  • cancelled (поставщик подтвердил отмену).
  • unknown_external_state (поставщик не ответил — обработка через BookingRestorationJob).
  • failed (поставщик отказал в отмене из-за политики — например, неотменяемый тариф; платформа может оспорить или принять).

12. cancelled — отменено

Терминальное состояние успешной отмены.

Инварианты:

  • Поставщик подтвердил отмену (или отмена не требовала поставщика).
  • Refund создан и обрабатывается (или платёж был только authorized — тогда void без возврата).
  • Settlement обновлён или отменён.
  • Событие booking.cancelled опубликовано.

Допустимые переходы: нет (terminal state).

13. amendment_in_progress — выполняется изменение

После confirmed запрошено изменение бронирования (даты, состав путешественников, тип номера, и т.д.). Это сложный поток, потому что меняется существующее бронирование, а не создаётся новое.

Инварианты:

  • Сохранён prior_state_snapshot для отката.
  • Запрос на изменение отправлен поставщику.
  • Применяется политика изменений (некоторые изменения — бесплатные, некоторые — с доплатой).

Допустимые переходы:

  • platform_confirmed (изменение принято, состояние обновлено; возможно создание дополнительного PaymentIntent для доплаты).
  • amendment_failed (изменение отклонено — возврат к prior state).
  • unknown_external_state (поставщик не ответил).

14. completed — исполнено

Терминальное успешное состояние. Дата выезда прошла, бронирование выполнено.

Инварианты:

  • Дата выезда (checkout_date) в прошлом.
  • Settlement переведён в cleared (выплата поставщику ожидается).
  • Событие booking.completed опубликовано.
  • Window для подачи возвратного платежа (chargeback) ещё активно (типично 60–180 дней) — мониторится отдельно.

Допустимые переходы: нет (terminal state). Возможные пост-обработки (возвратный платёж, спор) обрабатываются как отдельные сущности (Chargeback), не меняют состояние Booking.

Граф переходов с триггерами

Полный граф (ребро = переход с триггером):

draft
└─ user_submit_action → submitted
└─ user_cancel_action → cancel_requested → cancelled (быстро)

submitted
├─ pre_checks_passed_revalidation_required → pending_revalidation
├─ pre_checks_passed_revalidation_skipped → pending_supplier_confirmation
├─ pre_checks_failed → failed
└─ user_cancel_action → cancel_requested

pending_revalidation
├─ supplier_revalidated_offer_actual → pending_supplier_confirmation
├─ supplier_revalidated_offer_outdated → failed
├─ supplier_timeout → unknown_external_state
└─ user_cancel_action → cancel_requested

pending_supplier_confirmation
├─ supplier_callback_confirmed → supplier_confirmed
├─ supplier_callback_partial → partially_confirmed
├─ supplier_callback_rejected → failed
├─ supplier_timeout → unknown_external_state
└─ user_cancel_action → cancel_requested (сложный случай)

supplier_confirmed
└─ platform_invariants_satisfied → platform_confirmed

partially_confirmed
├─ user_accepts_partial → platform_confirmed (с частичной фиксацией платежа)
└─ user_rejects_partial → cancel_requested

platform_confirmed
├─ checkout_date_passed → completed
├─ cancel_initiated → cancel_requested
└─ amend_initiated → amendment_in_progress

unknown_external_state
├─ recovery_clarified_state_pending → pending_supplier_confirmation
├─ recovery_clarified_state_confirmed → supplier_confirmed
├─ recovery_clarified_state_failed → failed
└─ operator_decision → cancel_requested

cancel_requested
├─ requires_supplier_action → cancel_in_progress_supplier
└─ no_supplier_action_required → cancelled

cancel_in_progress_supplier
├─ supplier_callback_cancelled → cancelled
├─ supplier_callback_refused → failed (особый случай — оспаривание)
└─ supplier_timeout → unknown_external_state

amendment_in_progress
├─ supplier_accepted_amendment → platform_confirmed (с обновлёнными параметрами)
├─ supplier_rejected_amendment → amendment_failed → возврат к prior_state (snapshot восстанавливается)
└─ supplier_timeout → unknown_external_state

Все остальные переходы — запрещены на уровне state machine. Попытка сделать запрещённый переход (например, перевести cancelled обратно в confirmed) блокируется с ошибкой invalid_state_transition.

Идемпотентность

Принцип

Каждая операция над бронированием может прийти повторно — из-за тайм-аута и повтора партнёра, из-за повторной доставки события, из-за ручного replay. Платформа обязана обрабатывать повторы корректно — не создавать дубликат, не выполнять операцию дважды.

Каноничный механизм

Все критические операции принимают idempotency_key от вызывающего:

  • create_booking(idempotency_key, ...) — создание.
  • confirm_booking(idempotency_key, ...) — подтверждение.
  • cancel_booking(idempotency_key, ...) — отмена.
  • amend_booking(idempotency_key, ...) — изменение.
  • complete_booking(idempotency_key, ...) — завершение.

При получении операции:

  1. Платформа ищет BookingStateTransition с тем же idempotency_key для этого booking_id за окно дедупликации (типично 24 часа).
  2. Если найдено — возвращается результат предыдущей операции, без повторного выполнения.
  3. Если не найдено — операция выполняется, ключ записывается.

Идемпотентность с поставщиком

Запросы к поставщику также должны быть идемпотентными. Каждый запрос содержит supplier_request_id (генерируется платформой, передаётся поставщику). При повторе платформой того же запроса — поставщик возвращает результат предыдущего, не создаёт второе бронирование у себя.

Если поставщик не поддерживает идемпотентность — платформа обязана реализовать защиту через свой журнал (не отправлять второй запрос с тем же supplier_request_id, пока не получен ответ на первый).

Защита от двойных списаний

Если операция требует фиксации платежа (PaymentIntent.captured) — фиксация строго один раз через идемпотентность платежа (см. Платёжный домен).

При неудаче (например, тайм-аут после фиксации платежа, но до сохранения состояния platform_confirmed) — повторная попытка обнаруживает, что платёж уже зафиксирован, и просто завершает переход состояния, не повторяя фиксацию.

Различение supplier_confirmed и platform_confirmed

Зачем два состояния

Уже зафиксировано в Семантика предложений, цены, бронирования: «потому что supplier positive response и platform-level commit finalization не всегда являются одним атомарным моментом».

Конкретно, между моментом получения положительного ответа от поставщика и моментом полной готовности бронирования на платформе должны выполниться:

  • Фиксация платежа (PaymentIntent.captured).
  • Создание Settlement для взаиморасчёта.
  • Применение политики маржи и налогов.
  • Публикация доменного события booking.confirmed.
  • Запуск пост-обработки (генерация документов клиенту, обновление аналитики, обновление поисковой проекции если применимо, и т.д.).

Каждое из этих действий может занять время или провалиться. Без явного промежуточного состояния supplier_confirmed:

  • Если платёж не зафиксировался после ответа поставщика — есть бронирование у поставщика, но нет денег у платформы. Финансовая дыра.
  • Если событие не опубликовалось — внутренние контуры не узнают о бронировании. Settlement не создаётся.
  • Если пост-обработка не запустилась — клиент не получает подтверждение. Жалобы и chargeback.

Состояние supplier_confirmed явно фиксирует «поставщик уже подтвердил, мы должны довести инварианты». Это критическое состояние для операционного восстановления.

Внешний вид

Для внешних потребителей (партнёров, клиентов, агентов) supplier_confirmed и platform_confirmed объединены под именем confirmed. Различение видно только:

  • Операционным командам платформы.
  • В аналитике (для расчёта операционных метрик).
  • В журнале аудита.

Это намеренное упрощение наружу — клиент не должен думать о двух состояниях.

Каноничные таймауты для supplier_confirmed → platform_confirmed

Переход должен занимать секунды, не минуты. Если переход занимает больше:

  • Срабатывает алерт оперативной команде.
  • Запускается BookingRestorationJob для расследования.
  • При длительной задержке (более 5 минут) — эскалация.

Эта ситуация — операционный инцидент, не нормальное поведение.

Обработка unknown_external_state

Принцип

Поставщики — внешние системы с разной надёжностью. Тайм-ауты, разрывы соединений, некорректные ответы — реальность операций. Платформа не имеет права «забывать» бронирования с неопределённым состоянием.

unknown_external_stateоперационная категория первого класса — состояние с обязательным циклом восстановления.

Восстановительный цикл

Бронирование переходит в unknown_external_state

Создаётся BookingRestorationJob со state_before_restoration

Job планируется к запуску с экспоненциальным откатом:
- Попытка 1: через 1 минуту
- Попытка 2: через 5 минут
- Попытка 3: через 15 минут
- Попытка 4: через 1 час
- Попытка 5: через 6 часов
- Попытка 6: через 24 часа

На каждой попытке:
- Запрос статуса у поставщика по supplier_request_id
- Анализ ответа

Возможные исходы:
├── Поставщик ответил — состояние выяснено → переход к соответствующему состоянию
├── Поставщик не ответил — следующая попытка
└── После 6 попыток (около 32 часов) — эскалация оператору

Эскалация — ручное расследование оператором:
- Звонок поставщику
- Проверка через альтернативные каналы
- Решение по конкретному случаю

Оператор фиксирует решение через manual_override переход

Защита клиента

Во время unknown_external_state клиент не должен страдать:

  • PaymentIntent остаётся в authorized (платёж зарезервирован, но не списан) — клиент не теряет деньги.
  • Клиент уведомляется о ситуации с разъяснением (см. Уведомления и коммуникации).
  • При длительной неопределённости (более 24 часов) клиент имеет право на принудительную отмену с полным возвратом резервации.

Журнал для аудита

Все попытки восстановления, все ответы поставщика, все решения оператора — фиксируются в BookingRestorationJob.actions_taken. Это:

  • Доказательная база для регулятора при спорах.
  • Материал для аналитики качества поставщиков.
  • Обучающий материал для будущих случаев.

Жёсткие гарантии завершения цикла unknown_external_state

Раздел добавлен после внешнего архитектурного ревью 30.04.2026, в котором отмечен риск: «если этот статус не имеет жёсткого автоматического тайм-аута с переходом в failed или manual_intervention, система накопит зависшие деньги».

Раздел фиксирует жёсткие гарантии того, что состояние unknown_external_state всегда завершается в конечное (terminal или operationally final) состояние в рамках детерминированных временных окон, и что в течение этого периода не образуется «зависших денег» ни на стороне клиента, ни на стороне партнёра.

Финальный deadline цикла восстановления

Восстановительный цикл (6 попыток с экспоненциальным backoff'ом до ~32 часов) никогда не завершается оставлением бронирования в unknown_external_state. По истечении окна восстановления автоматически выполняется один из переходов:

  • failed — если в ходе попыток получены явные сигналы неудачи (например, поставщик в одной из попыток вернул not_found или cancelled для supplier_request_id).
  • manual_intervention — если поставщик не отвечает или отвечает противоречиво в течение всего окна. Этот переход обязательный, не опциональный.

manual_intervention — это operationally final state в рамках state machine: дальнейшие переходы возможны только через явное действие оператора (manual_override с зафиксированным reason). Бронирование не может оставаться в manual_intervention без зарегистрированного BookingRestorationJob с открытым actions_taken[] и assigned-оператором.

Финансовые гарантии в течение unknown_external_state

В течение всего цикла восстановления и в manual_intervention:

На стороне клиента:

  • PaymentIntent находится в authorized (зарезервирован), не captured (не списан).
  • Через 24 часа в unknown_external_state клиент имеет право на принудительную отмену с полным возвратом резервации (см. Уведомления и коммуникации).

На стороне партнёра (в гибридной merchant-of-record модели):

  • Партнёрский баланс не блокируется навсегда за бронированиями в unknown_external_state.
  • Каноничное правило: средства, зарезервированные на партнёрском балансе под бронирование, освобождаются автоматически по timeout-окну (см. правило unknown_state_balance_release в partner-finance-and-clearing.md).
  • Если в момент финального исхода (failed или manual_intervention) выясняется что обязательство всё-таки возникло — баланс корректируется обратно через explicit clearing_correction_event с audit trail.
  • Это устраняет риск ситуации «партнёрские деньги заморожены навсегда из-за молчания поставщика».

На стороне платформы (insolvency protection):

  • Если бронирование переходит в manual_intervention и далее в failed, но при этом end-customer уже совершил платёж — платформа обязана вернуть end-customer'у через нашего партнёра независимо от того, вернул ли поставщик (per Package Travel Directive insolvency protection).
  • Разница покрывается через loss accounting (см. раздел Settlement Split в partner-finance-and-clearing.md).

Связь с SLI и runbook'ом инцидента

Доля бронирований в unknown_external_stateкритическая операционная метрика платформы, один из 10 каноничных SLI (см. operations/sla-and-on-call-model.md, метрика unknown_external_state_share).

Целевые значения:

  • Premium-уровень: менее 1% от активных бронирований.
  • Standard-уровень: менее 3%.
  • Free-уровень: best effort.

Триггеры эскалации:

  • unknown_external_state_share > 1% для премиум-уровня — алерт tier_3_oncall (oncall поставщикам и архитекторам ingestion'а).
  • unknown_external_state_share > 5% для любого уровня — tier_1_incident (несколько supplier'ов одновременно ведут себя аномально, возможна инфраструктурная проблема платформы).
  • Конкретный supplier генерирует более 10% бронирований в unknown_external_state за 24 часа — анализ его supplier quality dashboard, возможна временная приостановка ingestion'а от него (см. data-governance-and-matching.md, раздел Governance И Supplier Quality).

Runbook каноничного инцидента unknown_external_state_share_breach:

  • Identify: какой supplier даёт наибольший вклад в долю.
  • Quarantine: временная приостановка новых бронирований через этого supplier'а, если вклад > 50%.
  • Communicate: уведомление аффектированных партнёров о деградации.
  • Investigate: контакт с supplier'ом (auto-ticket в их support system + escalation к contractual TAM).
  • Recover: после устранения проблемы — запуск ускоренного восстановительного цикла для застрявших бронирований.

Полный runbook — в operations/runbooks-incident-playbooks.md.

Защита от cumulative deadlock'ов

Дополнительная гарантия: общее число открытых BookingRestorationJob в системе ограничено сверху (max_concurrent_restoration_jobs, по умолчанию 10000). При достижении лимита:

  • Новые бронирования, попадающие в unknown_external_state, немедленно переходят в manual_intervention без проходжения восстановительного цикла.
  • Это circuit breaker на уровне платформы: если что-то системно сломалось и одновременно «застряли» десятки тысяч бронирований, платформа не пытается auto-restore все из них (создаст лавину запросов к supplier'ам), а передаёт оператору.
  • Лимит — конфигурируемый, обновляется в runtime без redeploy.

Это исключает сценарий «системный бот платформы DDOS'ит supplier'а попытками восстановления» при инфраструктурной проблеме.

Каноничные таймауты для unknown_external_state — обновлённые

Уточнения к таблице каноничных таймаутов раздела Каноничные таймауты ниже:

СостояниеHard timeoutФинальный исход
unknown_external_state (search-флоу)15 минутfailed или manual_intervention
unknown_external_state (booking-флоу, до confirmed)4 часаfailed или manual_intervention
unknown_external_state (post-confirmed, например cancel/amendment in progress)32 часа (восстановительный цикл из 6 попыток)manual_intervention
manual_interventionнет timeout, но есть SLA на assigned-оператора (8 часов)→ resolution через manual_override

Search-флоу 15 минут и booking-флоу 4 часа — это hard caps, после которых автоматический переход обязателен. Расширение восстановительного цикла до 32 часов применяется только для уже подтверждённых бронирований (post-confirmed), где cancel/amendment не получил подтверждения от supplier'а — там быстрая эскалация недопустима, потому что у клиента уже есть подтверждённый booking, и преждевременный переход в failed создаст финансовые последствия.

Обоснование (тезисы)

Тезис 1. unknown_external_state обязан завершаться в детерминированный конечный исход.

Альтернативы: (а) оставлять бронирование в unknown_external_state сколь угодно долго; (б) hard timeout с автоматической эскалацией.

Trade-off: вариант (а) приводит к деградации SLI и накоплению «зависших денег», что прямо отметил внешний ревьюер; вариант (б) — каноничный, обеспечивает predictable behavior.

Тезис 2. Финансовое состояние партнёра не должно блокироваться внутри неопределённости поставщика.

Альтернативы: (а) блокировать партнёрский баланс до итогового исхода; (б) освобождать баланс по timeout с возможной коррекцией; (в) не резервировать баланс вообще до confirmed.

Trade-off: вариант (а) делает партнёра заложником ненадёжности поставщика; вариант (в) допускает overdraft партнёром в момент confirmed; вариант (б) — каноничный баланс защиты партнёра и финансовой корректности.

Тезис 3. Cumulative circuit breaker на уровне платформы предотвращает каскадные сбои.

Альтернативы: (а) безлимитное число параллельных restoration jobs; (б) hard limit с обходом через manual_intervention.

Trade-off: вариант (а) при инфраструктурной проблеме создаёт лавину запросов к supplier'ам, ухудшая ситуацию; вариант (б) — каноничный паттерн bulkhead/circuit breaker.

Replay-safe механизмы

Восстановление через журнал событий

Все доменные события бронирования (booking.submitted, booking.supplier_confirmed, booking.platform_confirmed, booking.cancelled, и т.д.) — публикуются в каноничный журнал событий.

При сбое сервиса бронирования (потеря состояния в памяти, краш) текущее состояние любого бронирования полностью восстанавливается воспроизведением событий из журнала. Каждое событие применяется к state machine, давая итоговое состояние.

Это критическое свойство устойчивости:

  • Невозможно «потерять» бронирование из-за сбоя сервиса.
  • Корректность state machine гарантируется математически — операции применения событий коммутативны и идемпотентны в правильном порядке.
  • Возможен debug — реконструкция любого исторического состояния по любой временной точке.

См. Событийная шина и асинхронная дисциплина для общих правил replay.

Идемпотентность применения событий

При повторной доставке события — состояние не меняется (если событие уже применено). Контрольная точка — last_applied_event_id на сущности Booking.

Снимки состояния (state snapshots)

Для оптимизации — периодические снимки состояния бронирования. При восстановлении — replay только событий после последнего снимка. Это снижает время восстановления для бронирований с длинной историей.

Каноничные таймауты

Каждое ожидающее состояние имеет таймаут:

СостояниеТаймаутДействие при истечении
submitted30 секунд (для синхронных pre-checks)failed (pre_checks_timeout)
pending_revalidation60 секундunknown_external_state
pending_supplier_confirmation5 минут (синхронные поставщики), до 24 часов (асинхронные с явной seller policy)unknown_external_state
supplier_confirmed → platform_confirmed5 минут (нормально — секунды)→ алерт оперативной команде, restoration job
partially_confirmed (ожидание решения клиента)30 минут (B2C), 24 часа (B2B)cancel_requested (по политике)
cancel_in_progress_supplier5 минут (типично)unknown_external_state
amendment_in_progress5 минутunknown_external_state

Таймауты — конфигурируемые через tenant_configuration для определённых тенантов (например, корпоративные могут иметь другие SLA).

Запрещённые переходы

State machine блокирует следующие классы переходов:

  • ❌ Любой переход из terminal state (failed / cancelled / completed).
  • ❌ Переход назад во времени (например, confirmed → submitted).
  • ❌ Переход через несовместимое состояние (например, submitted → completed без прохождения confirmed).
  • ❌ Переход без выполнения предусловий (попытка pending_supplier_confirmation → supplier_confirmed без получения ответа от поставщика).
  • ❌ Переход из supplier_confirmed напрямую в failed (нарушение финансовой целостности — мы должны клиенту услугу).
  • ❌ Прямой переход в platform_confirmed минуя supplier_confirmed (нарушение каноничного порядка).

Попытка запрещённого перехода:

  • Возвращает ошибку invalid_state_transition с указанием текущего состояния и запрошенного.
  • Журналируется как BookingInvariantCheck с severity error.
  • При систематических попытках — эскалация (возможна ошибка в коде).

Стыковка с платёжным контуром

Полная таблица стыковки — в Платёжный домен, здесь — обратная сторона:

Состояние BookingДопустимое состояние PaymentIntent
draftcreated или отсутствует
submittedcreated / selecting_psp / awaiting_method
pending_revalidationawaiting_method или processing
pending_supplier_confirmationauthorized (платёж зарезервирован)
supplier_confirmedauthorized (фиксация в процессе)
platform_confirmedsucceeded (фиксация завершена)
partially_confirmedauthorized (ожидание решения клиента)
unknown_external_stateauthorized (НЕ succeeded до выяснения)
failedvoided или refunded (если фиксация была)
cancel_requestedauthorized или succeeded (зависит от стадии)
cancel_in_progress_supplierauthorized или succeeded
cancelledvoided (без списания) или refunded (с возвратом)
amendment_in_progresssucceeded (предыдущий) + новый PaymentIntent для доплаты при необходимости
completedsucceeded (может быть partially_refunded после частичной отмены)

При любом нарушении этой стыковки — BookingInvariantCheck с invariant_class: payment_state_consistent срабатывает и блокирует переход.

Стыковка с сагой пакетного тура

См. Операционная модель конструктора туров для деталей TourBookingTransaction.

Каждый компонент пакетного тура — отдельный Booking со своим жизненным циклом. Сага координирует их атомарность:

  • При успехе всех компонентов — все переходят в platform_confirmed синхронно.
  • При провале одного — выполняются компенсирующие переходы для уже подтверждённых.

Каноничное поле BookingStateTransition.saga_step_id фиксирует, какой шаг саги вызвал переход. Это даёт полный аудит распределённой транзакции через журнал.

События статусной машины бронирования

Каноничный набор событий, публикуемых при переходах:

СобытиеКогдаГлавные потребители
booking.submittedПереход в submittedКонтур валидации, аналитика
booking.revalidation_requiredПереход в pending_revalidationКонтур наблюдаемости
booking.supplier_request_sentЗапрос отправлен поставщикуКонтур наблюдаемости поставщика
booking.supplier_confirmedПереход в supplier_confirmedПлатёжный контур (для запуска фиксации)
booking.confirmedПереход в platform_confirmedВсе потребители — пост-бронирование, settlement, аналитика, уведомления
booking.partially_confirmedПереход в partially_confirmedКонтур уведомлений (запрос решения клиента)
booking.unknown.external.stateПереход в unknown_external_stateСрочный алерт оперативной команде, restoration scheduler
booking.restoration_attemptedКаждая попытка восстановленияКонтур наблюдаемости
booking.restoration_clarifiedВосстановление выяснило состояниеКонтур наблюдаемости
booking.cancel_requestedПереход в cancel_requestedПлатёжный контур, контур уведомлений
booking.cancelledПереход в cancelledПлатёжный контур (для refund), settlement, аналитика
booking.amendment_startedПереход в amendment_in_progressКонтур пост-бронирования
booking.amendment_succeededВозврат в platform_confirmed после измененияКонтур пост-бронирования
booking.amendment_failedВозврат к prior stateКонтур уведомлений
booking.failedПереход в failedКонтур наблюдаемости, контур уведомлений
booking.completedПереход в completedSettlement (запуск выплаты), аналитика
booking.invariant_violation_detectedОбнаружено нарушение инвариантаСрочный алерт оперативной команде

Полная таксономия — в Первоначальная таксономия событий и в JSON Schema Like Field Catalogs.

Метрики и наблюдаемость

Каноничные метрики state machine

  • Распределение по состояниям — сколько бронирований в каждом состоянии в реальном времени.
  • Среднее время в каждом состоянии — индикатор операционной скорости. Долгое нахождение в pending_supplier_confirmation — сигнал проблем у поставщика.
  • Доля переходов в unknown_external_state — главная метрика устойчивости. Цель — менее 1%.
  • Доля успешного восстановления из unknown_external_state — цель более 95%.
  • Среднее время восстановления — операционный показатель.
  • Доля бронирований, проходящих весь путь до completed — главная продуктовая метрика.
  • Доля бронирований, проходящих через partially_confirmed — продуктовый сигнал (плохое качество доступности у поставщиков).
  • Распределение причин failed — для оптимизации pre-checks.
  • Доля переходов через amendment_in_progress — продуктовый сигнал (как часто клиенты меняют бронирования).

Алерты

Через Аналитика и бизнес-аналитика и Наблюдаемость и реагирование на инциденты:

  • Алерт при доле unknown_external_state выше 2% за час — оперативная команда расследует.
  • Алерт при превышении таймаута supplier_confirmed → platform_confirmed (более 5 минут) — критический инцидент.
  • Алерт при систематических BookingInvariantCheck нарушениях для конкретного типа — возможна ошибка кода.
  • Алерт при росте failed с конкретной причиной — возможна деградация поставщика или политики.

Фазы развёртывания state machine

Фаза Bootstrap (0–6 месяцев)

Что разворачивается:

  • Каноничная модель Booking, BookingStateTransition, BookingInvariantCheck, BookingRestorationJob зафиксирована в схеме хранения.
  • Базовая state machine с упрощённым набором состояний для первого тенанта (vitrip.store).
  • Идемпотентность критических операций.
  • Простой механизм восстановления для unknown_external_state (без расширенной автоматики).
  • Базовая публикация событий бронирования.

Триггер выхода:

  • Готовность к partner production launch — нужна полная зрелая state machine.

Фаза 2 — Production state machine (6–18 месяцев)

Что разворачивается:

  • Все 14 каноничных состояний.
  • Полные инварианты на каждом переходе.
  • Расширенный механизм восстановления с экспоненциальным откатом.
  • Replay-safe журнал событий с возможностью полного восстановления состояния.
  • Стыковка со state machine PaymentIntent.
  • Каноничные таймауты для каждого состояния.
  • Журнал переходов с идемпотентностью.

Триггер выхода:

  • Появление пакетных туров через сагу.
  • Появление амэндмэнтов на масштабе.

Фаза 3 — Smart booking (18–30 месяцев)

Что разворачивается:

  • Полная стыковка с сагой пакетного тура.
  • Расширенный амэндмэнт-flow.
  • ML-модели прогнозирования провалов (anomaly detection в state machine).
  • Автоматическая эскалация на основе паттернов.
  • Расширенная аналитика state machine для тенантов (Professional+).

Фаза 4 — Multi-region (30+ месяцев)

Что разворачивается:

  • Multi-region обработка с локализацией данных.
  • Federated state machine для крупных корпоративных партнёров.
  • Расширенная защита от регионально-специфичных видов сбоев.

Архитектурные решения с тезисным обоснованием

Решение 1. 14 явных состояний, не упрощённая модель

Цель: обеспечить точную обработку каждой операционной ситуации.

Тезисы:

  1. Упрощённые модели (например, 4 состояния: pending / confirmed / cancelled / failed) не отражают реальности работы с поставщиками — теряются состояния partially_confirmed, unknown_external_state, amendment_in_progress, что приводит к ad-hoc обработке и ошибкам.
  2. Каждое из 14 состояний — операционно различимо и требует разной обработки.
  3. Современные платформы (Stripe, Booking.com, Airbnb) — все строят детальные state machines в финансовых контурах. Это база.

Решение 2. Различение supplier_confirmed и platform_confirmed

Цель: разделить момент ответа поставщика и момент полной готовности бронирования на платформе.

Тезисы:

  1. Без различения — финансовые дыры при сбое между ответом поставщика и фиксацией платежа.
  2. Различение даёт явный operational moment для аудита и восстановления.
  3. Внешнее упрощение под confirmed сохраняет user experience.

Решение 3. unknown_external_state — first-class категория

Цель: обеспечить устойчивость при ненадёжных внешних поставщиках.

Тезисы:

  1. Тайм-ауты и неопределённые ответы поставщиков — реальность, не исключение. Без явного состояния — ad-hoc обработка с потерями.
  2. Явная категория с восстановительным циклом — единственный способ дать гарантии клиенту.
  3. Современные платформы с распределёнными зависимостями (Uber, Stripe, AWS) — все имеют явные unknown states. Это база.

Решение 4. Идемпотентность через idempotency_key для всех критических операций

Цель: защита от двойных списаний и дубликатов при повторах.

Тезисы:

  1. Сетевые сбои и повторы — реальность партнёрской интеграции. Без идемпотентности — двойные списания и дубликаты.
  2. Идемпотентность через ключ — стандарт индустрии (Stripe, Square).
  3. Архитектурное решение защищает независимо от добросовестности партнёрского кода.

Решение 5. Replay-safe через журнал событий

Цель: обеспечить полное восстановление состояния при сбое любого компонента.

Тезисы:

  1. Восстановление из БД-снимка может потерять in-flight операции.
  2. Журнал событий с воспроизведением — единственный способ полного восстановления.
  3. Замена сервиса бронирования (rollback версии) не приводит к потере данных — события переигрываются.

Открытые развилки

Развилка 1. Тонкая настройка таймаутов для разных классов поставщиков

Высокотехнологичные поставщики отвечают за секунды; legacy XML-поставщики могут отвечать минутами. Тайм-ауты должны быть различны для разных классов поставщиков. Каноничная классификация и таймауты — открытая развилка.

Эскалируется: при подключении второго поставщика с другим профилем задержки.

Развилка 2. Автоматическая обработка некоторых случаев unknown_external_state через ML

ML-модель может предсказывать, в каком конечном состоянии окажется бронирование (на основе профиля поставщика, времени суток, типа продукта). Применение к автоматическому решению — открытая развилка фазы 3.

Эскалируется: при создании ML-стратегии в Платформа машинного обучения.

Развилка 3. Расширенный амэндмэнт-flow

Текущая модель поддерживает базовое изменение через возврат к prior_state при провале. Сложные амэндмэнты (изменение нескольких параметров, цепочки амэндмэнтов) — открытая развилка.

Эскалируется: при появлении партнёрских запросов на сложные сценарии.

Развилка 4. Поддержка частичных компенсаций в саге

Текущая модель компенсации в саге — полный откат всех успешных компонентов. Альтернатива — частичные компенсации (попытка сохранить часть и компенсировать только провалившиеся). Сложнее, но даёт лучший пользовательский опыт.

Эскалируется: при оптимизации опыта в фазе 3.

Развилка 5. Time-shifted бронирования

Некоторые поставщики позволяют забронировать с отсроченной датой подтверждения (например, через 24 часа после запроса). Это требует расширенного state machine с дополнительным состоянием reservation_held_pending_supplier_decision. Текущая модель упрощённо относит к pending_supplier_confirmation с длинным таймаутом.

Эскалируется: при подключении поставщиков с такой моделью.

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

Корневые архитектурные документы

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

Документы развития

Операционная сторона

Архитектурные правила