gendesign/tradein-mvp/backend/data/sql/279_payments_live_checkout_uidx.sql
bot-backend a48070dd89
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / changes (pull_request) Successful in 11s
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 4m54s
CI Trade-In / changes (pull_request) Successful in 8s
CI Trade-In / frontend-checks (pull_request) Has been skipped
fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.

Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.

Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
2026-08-29 19:38:38 +05:00

65 lines
4.5 KiB
PL/PgSQL
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

-- 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;