-- 279_payments_live_checkout_uidx.sql -- Идемпотентность checkout на уровне БД: не больше одного ЖИВОГО платежа на -- пару (estimate_id, product_code). -- -- ── WHY ────────────────────────────────────────────────────────────────────── -- До этой миграции переиспользование живого платежа в -- app/api/v1/payments.py::checkout держалось на «SELECT, потом INSERT» — ровно -- на том, что шапка того же модуля называет дефектом. Обычный двойной клик по -- кнопке оплаты даёт два параллельных запроса: оба проходят SELECT до того, как -- любой из них вставит строку, оба делают INSERT, оба зовут Init — и на карте -- покупателя появляются ДВА ХОЛДА. Порядок выполнения тут ничего не решает, -- решает уникальный ключ: второй INSERT обязан отбиться самой БД. -- -- ── Почему частичный, а не полный UNIQUE(estimate_id, product_code) ────────── -- Полный запретил бы повторную покупку навсегда: после CANCELED/REJECTED -- человек вправе попробовать оплатить заново, а после CONFIRMED — купить -- второй раз. Поэтому в предикате только «живые» статусы — те, при которых -- платёжная сессия ещё не завершилась. Список = _REUSABLE_STATUSES -- (= _PRE_CONFIRM_STATUSES) в app/api/v1/payments.py; расхождение ловит -- tests/test_payments_router.py::test_live_status_predicate_matches_code — -- индекс, который шире кода, запрещает легитимный повтор, а который уже — -- пропускает второй холд. -- -- Предикат ссылается только на неизменяемые выражения (сравнение колонок с -- литералами), поэтому индекс легален как частичный, а строка входит в него и -- выходит автоматически при UPDATE статуса. -- -- estimate_id IS NOT NULL — обязательная часть предиката: колонка nullable -- (FK ON DELETE SET NULL, 233_payments.sql), а обычный UNIQUE считает NULL -- отличным от NULL, так что без этого условия строки с NULL просто копились бы -- в индексе без пользы. -- -- ── Безопасность применения ───────────────────────────────────────────────── -- Контур выключен (PAYMENTS_ENABLED=false), таблица на проде пуста -- (проверено 2026-08-29: SELECT count(*) FROM payments → 0), поэтому обычный -- CREATE UNIQUE INDEX не может упасть на существующих дублях и не блокирует -- ничью запись. IF NOT EXISTS — повторное применение безвредно. BEGIN; -- Конвенция проекта (#2752): CREATE UNIQUE INDEX на существующей таблице берёт -- блокировку и без lock_timeout встанет в очередь за чужой сессией. SET LOCAL lock_timeout = '5s'; CREATE UNIQUE INDEX IF NOT EXISTS payments_live_estimate_product_uidx ON payments (estimate_id, product_code) WHERE estimate_id IS NOT NULL AND status IN ( 'NEW', 'FORM_SHOWED', 'PREAUTHORIZING', 'AUTHORIZING', 'AUTHORIZED', '3DS_CHECKING', '3DS_CHECKED', 'CHECKING', 'CHECKED', 'PROCESSING', 'CONFIRMING' ); COMMENT ON INDEX payments_live_estimate_product_uidx IS 'Не больше одного живого платежа на (estimate_id, product_code): двойной ' 'клик по кнопке оплаты не создаёт второй холд на карте покупателя.'; COMMIT;