Как в PDP закрыли повторную оплату брони
В PDP добавили защиту от дублей платежей на двух уровнях: в бизнес-логике приложения и в БД. Отдельно обработали расхождения между банком и системой.

Для PDP сделали двойную защиту от повторной оплаты: проверку в приложении и ограничение в БД, которое не даёт создать пересекающиеся активные бронирования на одно помещение.
Проблема здесь неприятная не из-за кнопки «Оплатить». Кнопку можно заблокировать на фронте, можно показывать лоадер, можно спрятать повторный переход на платёжную ссылку. Но деньги и бронь живут на бэкенде, а там остаются гонки: два запроса почти одновременно, повторный callback от банка, пользователь вернулся из платёжной формы не тем маршрутом, состояние сделки ещё не успело смениться.
Для PDP правило было жёстким: на одно помещение может быть только одна активная сделка. Если это правило нарушается, дальше ломается уже бизнес-смысл бронирования: один объект нельзя продать двум покупателям, даже если оба платежа технически прошли.
Где одной проверки оказалось мало
Первый слой защиты оставили в бизнес-логике приложения. Перед созданием или переводом сделки в активное состояние проверяем, что по помещению нет другой активной сделки. Это удобное место для понятной ошибки: можно вернуть пользователю нормальный статус, не доводя ситуацию до исключения БД.
Но такая проверка сама по себе не закрывает гонку. Если два процесса одновременно прочитали состояние и оба увидели «активной сделки нет», они оба могут пойти дальше. На уровне кода всё выглядит аккуратно: проверили, создали, обновили статус. А в реальности между SELECT и записью есть окно.
Именно здесь легко начать копать не туда. Самый соблазнительный вариант — усилить фронт: отключать кнопку, хранить флаг отправки, не давать пользователю нажать второй раз. Это полезно для UX, но не является защитой платежей. Второй вариант — надеяться только на идемпотентность обработчика оплаты. Она нужна, но она не отвечает на вопрос, может ли второе бронирование занять то же помещение.
Поэтому второй слой сделали там, где состояние общее для всех процессов, — в базе данных. Ограничение запрещает пересечение бронирований, если они претендуют на одно помещение и находятся в активном состоянии. Приложение может ошибиться в порядке вызовов, запросы могут прийти одновременно, обработчик банка может повториться — БД всё равно не должна принять вторую активную сделку.
Что происходит, если банк и система разошлись
Отдельная часть задачи — платежи, которые банк считает успешными, а наша система не может корректно привязать к нормальной активной сделке. Например, из-за уже занятого помещения или конфликта состояний.
Для такого случая добавили отдельный статус: «оплачено с ошибкой». Это не успешная бронь и не обычная ошибка оплаты. Деньги в банке есть, но внутри системы сделка требует ручной или отдельной автоматической обработки.
Самый долгий спор был именно про этот статус. Очень хочется свести всё к бинарной модели: оплачено / не оплачено. Но она плохо описывает ситуацию, где банк уже сказал «да», а бизнес-правило по помещению не позволяет считать бронь валидной. Если спрятать это в общий статус ошибки, поддержка и бэкенд будут восстанавливать цепочку по логам. Если пометить явно, такие сделки сразу видны как отдельный класс инцидентов.
Что изменили в модели состояний
После доработки защита стала двухуровневой:
- приложение проверяет активные сделки до перехода в состояние брони;
- БД запрещает пересекающиеся активные бронирования на одно помещение;
- успешная оплата при конфликте не теряется, а попадает в статус «оплачено с ошибкой»;
- бизнес-правило «одно помещение — одна активная сделка» живёт не только в коде сценария, но и в хранилище данных.
Такой подход не отменяет аккуратную обработку платёжных ссылок и callback-ов от банка. Он делает другое: убирает зависимость целостности бронирования от того, в каком порядке пришли события.
Теперь при спорной ситуации система не пытается молча «дотянуть» сделку до успешной. Она либо создаёт одну активную бронь, либо фиксирует конфликт в отдельном состоянии, с которым уже можно работать дальше.