gendesign/tradein-mvp/backend/data/sql/233_payments.sql
bot-backend 2d62b87cf3
Some checks failed
Deploy Trade-In / build-backend (push) Blocked by required conditions
Deploy Trade-In / deploy (push) Blocked by required conditions
Deploy Trade-In / changes (push) Successful in 10s
Deploy Trade-In / build-frontend (push) Has been skipped
Deploy Trade-In / build-browser (push) Has been skipped
Deploy Trade-In / test (push) Has been cancelled
feat(tradein/payments): схема БД, конфиг и kill-switch платёжного контура (#2732)
Миграция 233_payments.sql (payments / payment_notifications / payment_entitlements), поля TBANK_* и PAYMENTS_ENABLED, fail-fast в lifespan. Бизнес-логики нет, контур выключен по умолчанию.

По итогам deep review: UNIQUE NULLS NOT DISTINCT на обоих дедуп-ключах, payment_notifications.processed_at, payments.pd_erased_at, payments_lead_idx, CHECK на длину order_id, статусы сверены с официальной openapi.yaml (Confirm-2, v1.24).
Co-authored-by: bot-backend <bot-backend@gendsgn.local>
Co-committed-by: bot-backend <bot-backend@gendsgn.local>
2026-08-06 16:33:27 +00:00

305 lines
24 KiB
PL/PgSQL
Raw Permalink 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.

-- 233_payments.sql
-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии
-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»).
-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на
-- месте по итогам review (статус HOLD), без ребейза номера. Дважды
-- переименована (git mv, история сохранена): 228 → 232 → 233. Номер 228
-- заняли 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql
-- (влились в main); 229 и 231 занимает открытый PR #2547; 232 занял открытый
-- PR #2742 (`232_listings_observation_time_meaning.sql`). Урок: сверять номер
-- нужно не только по `forgejo/main`, но и по ВСЕМ открытым PR-веткам — ни один
-- из этих файлов сам себя в `_manifest_applied.txt` не пишет (мы пишем), из-за
-- чего коллизия обнаруживается только тестом `test_new_files_do_not_reuse_prefix`
-- уже после того, как чей-то PR смержен первым.
--
-- ── WHY ──────────────────────────────────────────────────────────────────────
-- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в
-- app/core/config.py, тот же PR). Роутера, статус-машины и обработчика
-- нотификаций здесь НЕТ (появятся в PR-D/E; PR-C — token/tbank_client/receipt —
-- уже смержен, схемы не касается). До PAYMENTS_ENABLED=true эти три таблицы
-- просто не пишутся никаким кодом; создание сейчас разблокирует параллельную
-- разработку PR-D без гонки миграций.
--
-- ── WHAT ─────────────────────────────────────────────────────────────────────
-- payments — одна строка на попытку оплаты (Init → notify →
-- Confirm/Cancel). order_id — наш внутренний id,
-- уходит в T-Bank как OrderId (CHECK ≤50 симв. —
-- падать у себя, а не на /v2/Init); tbank_payment_id —
-- PaymentId из ответа Init, известен только ПОСЛЕ
-- вызова. pd_erased_at — см. отдельный блок ниже.
-- payment_notifications — append-only лог входящих вебхуков Т-Банка.
-- Идемпотентность нотификаций — это и есть
-- UNIQUE NULLS NOT DISTINCT(tbank_payment_id, status,
-- amount_kopecks, token): T-Bank шлёт AUTHORIZED и
-- CONFIRMED одновременно, дедуп через ON CONFLICT DO
-- NOTHING (сервисный код — PR-D). processed_at —
-- контракт fulfillment, см. блок ниже. Осознанно БЕЗ
-- CHECK на status: это сырой лог входящих данных,
-- узкий CHECK здесь означал бы, что недокументиро-
-- ванный/новый статус банка ломает запись самого
-- факта нотификации.
-- payment_entitlements — факт «что выдано за платёж» (доступ), НЕ кошелёк.
-- См. блок про amount/consumed ниже.
--
-- ── ИДЕМПОТЕНТНОСТЬ UNIQUE-ключей: NULLS NOT DISTINCT (найдено на проде) ────
-- Первая версия миграции использовала обычный UNIQUE на обоих ключах
-- дедупликации. В Postgres обычный UNIQUE считает NULL уникальным относительно
-- самого себя (NULL ≠ NULL) — при ref_id IS NULL / token IS NULL несколько
-- строк с одинаковым остальным набором колонок НЕ схлопываются. Это не
-- гипотетика: три одинаковых INSERT в payment_entitlements с ref_id IS NULL
-- дали три строки вместо одной при проверке на проде (до первого реального
-- применения этой миграции — воспроизведено отдельно). PG 16.4 (прод) умеет
-- `UNIQUE NULLS NOT DISTINCT` (с PG15) — NULL трактуется как равный NULL,
-- ровно то поведение, которое ожидает сервисный слой (ON CONFLICT DO NOTHING /
-- DO UPDATE). Применено к обоим дедуп-ключам ниже.
--
-- ── СТАТУСЫ T-BANK (payments.status CHECK) ──────────────────────────────────
-- Источник истины — официальная OpenAPI-спека:
-- https://developer.tbank.ru/schemas/eacq/openapi.yaml (OpenAPI 3.0.2, v1.24),
-- схема `Confirm-2`, 24 значения. На странице /eacq/intro/developer/openapi
-- прямо сказано: при расхождении прозы и спеки приоритет у спеки — поэтому
-- ссылка на спеку, а не на человекочитаемые доки.
--
-- ВАЖНО про GetState/CheckOrder (ручки, которыми реконсиляция PR-E читает
-- статус): в спеке их поле `Status` объявлено СВОБОДНОЙ строкой
-- (`maxLength: 20`, БЕЗ enum). То есть на ручках, которыми фактически питается
-- реконсиляция, банк словарь значений не фиксирует контрактно — наш CHECK
-- здесь строже, чем контракт поставщика. Это осознанный выбор (закрытый
-- список читается и валидируется проще, чем произвольная строка), а не
-- недосмотр; следующий читатель должен видеть, что этот CHECK может однажды
-- отвергнуть легитимный, но недокументированный `Confirm-2`-строкой статус —
-- см. контракт 'UNKNOWN' ниже.
--
-- Сверка по `Confirm-2` относительно первой версии миграции:
-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — отсутствуют в
-- `Confirm-2`. Дополнительно у поля `Status` в спеке `maxLength: 20`, а
-- 'AUTHORIZED_AND_CHARGED' — 22 символа: физически не может быть значением
-- этого поля, не только "не найдено", а невозможно по контракту.
-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — есть в `Confirm-2`.
-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' — отсутствуют в
-- `Confirm-2`, гипотеза не подтвердилась.
-- - 'PREAUTHORIZING' оставлен и подтверждён: есть в `Confirm-2`. (Ранее
-- редакция ссылалась на комментарий стороннего Go-клиента о том, что этот
-- статус будто бы убран из API — спекой это не подтверждается, комментарий
-- был неточным источником и снят.)
-- - ДОБАВЛЕНЫ пять пропущенных in-flight значений из `Confirm-2`:
-- 'CHECKING', 'CHECKED', 'PROCESSING', 'COMPLETING', 'COMPLETED'. Это
-- ровно те статусы, которые GetState/CheckOrder вернёт по зависшему
-- платежу — их читает реконсиляция (PR-E). Пропуск реального значения —
-- единственное опасное направление ошибки CHECK: не "лишний" статус
-- проскочит, а свой же CHECK отвергнет то, что банк реально прислал.
-- Порядок в списке ниже — по смысловой близости к соседним стадиям
-- (CHECKING/CHECKED рядом с 3DS_CHECKING/3DS_CHECKED, COMPLETING/COMPLETED
-- рядом с CONFIRMED), а не порядок из спеки — `Confirm-2` не гарантирует
-- порядок enum, для CHECK-констрейнта (проверка принадлежности множеству)
-- порядок значения не имеет.
-- - 'PARTIAL_REVERSED' и 'REFUND_FAILED' в `Confirm-2` ОТСУТСТВУЮТ. Оставлены
-- в CHECK как безвредный запас на случай появления в будущей версии API
-- (сам факт лишнего разрешённого значения в CHECK ничего не ломает — в
-- отличие от отсутствующего). Это отличается от предыдущей редакции
-- комментария, которая ошибочно утверждала, что сверка их "подтверждает":
-- не подтверждает, они не найдены в источнике истины.
--
-- Контракт: если банк присылает статус вне списка ниже, ОБРАБОТЧИК
-- НОТИФИКАЦИЙ И ЗАДАЧА РЕКОНСИЛЯЦИИ (GetState/CheckOrder, PR-E) обязаны
-- писать в payments.status значение 'UNKNOWN' (не поднимать исключение, не
-- терять запись) — сырое тело нотификации в любом случае лежит целиком в
-- payment_notifications.body (у GetState/CheckOrder своего append-only лога
-- нет — если реконсиляция сама не сохранит сырой ответ, факт неизвестного
-- статуса останется только в payments.status='UNKNOWN' и её собственных логах).
-- INSERT/UPDATE payments с любым другим незнакомым значением упадёт на
-- CHECK — это специально: тихое искажение статуса хуже, чем громкий сбой
-- одной записи.
--
-- ── payment_entitlements: без кредитно-кошельковой семантики ────────────────
-- Первая версия несла amount/consumed (модель «кредиты/пакеты»). Явно
-- отвергнуто в `mera-b2c-paid-flow-decision.md` (§1): «Что отвергнуто явно:
-- кредиты/пакеты, роль customer, ... — цена ошибки в guard'е выше годовой
-- выручки этой воронки». Доставка купленного выбрана через capability-URL
-- (`/r/<token>`, волна 3 §9 того же дока), а не через списание количества с
-- баланса. Таблица остаётся фактом «что выдано за платёж» (payment → kind
-- [+ ref_id]), без количественного состояния. Если модель когда-нибудь
-- реально понадобится — восстановить amount/consumed дешевле (ADD COLUMN),
-- чем сейчас снимать с них зависимости в PR-E, которого ещё нет.
--
-- ── payments.pd_erased_at: покрытие purge-контура #2547 ─────────────────────
-- #2547 знает про PII в trade_in_leads/trade_in_estimates, но НЕ про payments
-- — эта таблица нового хранилища ПДн (customer_email/customer_phone) появится
-- вместе с PR-D. pd_erased_at NULL = ПДн не стирались; проставляется по
-- запросу субъекта на удаление — обнуляет customer_email/customer_phone,
-- фискально значимые поля (order_id, amount_kopecks, confirmed_at,
-- terminal_key и т.д.) остаются нетронутыми (обязательны для чека/сверки с
-- банком). Сам purge-job — вне scope этого PR (схема-only); колонку дешевле
-- завести сейчас, чем добавлять отдельной миграцией после того как PR-D
-- начнёт писать ПДн в эту таблицу.
--
-- ── payment_notifications.processed_at: контракт fulfillment (для PR-D) ─────
-- Без этой колонки обработчик получается at-most-once по ОШИБКЕ: если процесс
-- упал ПОСЛЕ INSERT нотификации, но ДО выдачи товара (payment_entitlements /
-- инкремент квоты), ретрай банка увидит уже существующую строку через
-- ON CONFLICT DO NOTHING, ответит "OK" и товар не выдастся никогда — при этом
-- деньги у клиента уже списаны/захолдированы. Контракт для PR-D: обработка
-- нотификации считается завершённой (fulfillment состоялся) ТОЛЬКО когда
-- processed_at проставлен; сам факт наличия строки в payment_notifications
-- этого не гарантирует и не должен использоваться как признак «обработано».
--
-- ── IDEMPOTENCY ──────────────────────────────────────────────────────────────
-- CREATE TABLE IF NOT EXISTS + DROP CONSTRAINT IF EXISTS перед ADD CONSTRAINT
-- (безопасный re-run на CHECK). Ничего не удаляет и не бэкфиллит.
--
-- ── FK на trade_in_estimates / trade_in_leads ───────────────────────────────
-- Обе таблицы проверены по факту (001_trade_in_estimates.sql,
-- 172_trade_in_leads.sql): id uuid PRIMARY KEY DEFAULT gen_random_uuid() в
-- обеих — FK безопасен, типы совпадают. ON DELETE SET NULL — по образцу
-- уже существующего trade_in_leads.estimate_id (172_trade_in_leads.sql:11):
-- обе колонки здесь опциональные бизнес-ссылки, а не владеющая связь, удаление
-- estimate/lead не должно ронять запись о платеже.
--
-- Dependencies: 001_trade_in_estimates.sql, 172_trade_in_leads.sql.
-- Apply after: 230_house_merge_log.sql.
BEGIN;
-- ─────────────────────────────────────────────────────────────────────────
-- payments
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS payments (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id text NOT NULL UNIQUE CHECK (char_length(order_id) <= 50),
tbank_payment_id text UNIQUE, -- PaymentId из ответа Init (NULL до Init)
terminal_key text NOT NULL,
product_code text NOT NULL, -- что продали (product_code, не цена из тела запроса)
amount_kopecks bigint NOT NULL CHECK (amount_kopecks > 0),
currency text NOT NULL DEFAULT 'RUB',
status text NOT NULL DEFAULT 'NEW',
payment_url text,
created_by text, -- username (X-Authenticated-User), NULL если анонимный checkout
estimate_id uuid REFERENCES trade_in_estimates(id) ON DELETE SET NULL,
lead_id uuid REFERENCES trade_in_leads(id) ON DELETE SET NULL,
customer_email text,
customer_phone text,
pd_erased_at timestamptz, -- см. блок про purge-контур #2547 в шапке файла
error_code text,
error_message text,
init_response jsonb, -- сырой ответ T-Bank /v2/Init, для дебага
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
authorized_at timestamptz,
confirmed_at timestamptz,
refunded_at timestamptz
);
ALTER TABLE payments DROP CONSTRAINT IF EXISTS payments_status_check;
ALTER TABLE payments
ADD CONSTRAINT payments_status_check
CHECK (status IN (
'NEW',
'FORM_SHOWED',
'DEADLINE_EXPIRED',
'CANCELED',
'PREAUTHORIZING',
'AUTHORIZING',
'AUTHORIZED',
'AUTH_FAIL',
'REJECTED',
'3DS_CHECKING',
'3DS_CHECKED',
'CHECKING',
'CHECKED',
'PROCESSING',
'CONFIRMING',
'CONFIRMED',
'COMPLETING',
'COMPLETED',
'REVERSING',
'PARTIAL_REVERSED',
'REVERSED',
'REFUNDING',
'PARTIAL_REFUNDED',
'REFUNDED',
'REFUND_FAILED',
'UNKNOWN'
));
CREATE INDEX IF NOT EXISTS payments_status_created_idx ON payments (status, created_at);
CREATE INDEX IF NOT EXISTS payments_created_by_idx ON payments (created_by);
CREATE INDEX IF NOT EXISTS payments_estimate_idx ON payments (estimate_id);
-- lead_id имеет FK ON DELETE SET NULL — без индекса Postgres делает seq scan
-- по payments на каждый DELETE FROM trade_in_leads (проверка "нет ли ссылок"
-- перед SET NULL). #2547 вводит пакетное физическое удаление лидов — без
-- индекса это N seq scan'ов по payments на один batch-прогон purge-джобы.
CREATE INDEX IF NOT EXISTS payments_lead_idx ON payments (lead_id);
COMMENT ON TABLE payments IS
'Платёжный контур МЕРЫ (Т-Банк эквайринг). Одна строка на попытку оплаты. '
'Контур выключен по умолчанию — см. PAYMENTS_ENABLED в app/core/config.py.';
-- ─────────────────────────────────────────────────────────────────────────
-- payment_notifications — append-only, идемпотентность входящих вебхуков
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS payment_notifications (
id bigserial PRIMARY KEY,
order_id text,
tbank_payment_id text,
status text, -- сырой статус из тела, без CHECK (см. WHY выше)
amount_kopecks bigint,
token text,
token_valid boolean NOT NULL,
body jsonb NOT NULL, -- полное тело нотификации как есть
received_at timestamptz NOT NULL DEFAULT now(),
-- Контракт fulfillment для PR-D — см. подробный блок в шапке файла.
-- NULL = обработка (выдача товара) ещё не завершена или не начиналась;
-- проставляется сервисным кодом ПОСЛЕ успешной выдачи, не в момент INSERT.
processed_at timestamptz,
-- Дедуп-ключ идемпотентности (recon §3 п.4): T-Bank шлёт AUTHORIZED и
-- CONFIRMED одновременно для одностадийной оплаты; ON CONFLICT DO NOTHING
-- в сервисном коде (PR-D) значит "уже обработано". NULLS NOT DISTINCT
-- (см. блок в шапке файла) — без него NULL в token/tbank_payment_id не
-- считался бы дублем самого себя, и дедуп молча переставал бы работать
-- ровно в вырожденном случае, для которого он и нужен.
UNIQUE NULLS NOT DISTINCT (tbank_payment_id, status, amount_kopecks, token)
);
COMMENT ON TABLE payment_notifications IS
'Append-only лог входящих вебхуков T-Bank. Идемпотентность через UNIQUE '
'NULLS NOT DISTINCT(tbank_payment_id, status, amount_kopecks, token) + '
'ON CONFLICT DO NOTHING. processed_at — контракт "выдача состоялась" для PR-D.';
-- ─────────────────────────────────────────────────────────────────────────
-- payment_entitlements — что выдано за платёж (факт, не кошелёк)
-- ─────────────────────────────────────────────────────────────────────────
CREATE TABLE IF NOT EXISTS payment_entitlements (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
payment_id uuid NOT NULL REFERENCES payments(id),
subject text NOT NULL, -- username или anon-token, кому выдано
kind text NOT NULL, -- 'pdf_report' | 'report_link' | ...
ref_id uuid, -- estimate_id для разового отчёта, NULL если не применимо
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
-- Гарантия "выдали один раз" (recon §3). NULLS NOT DISTINCT (см. блок в
-- шапке файла) — без него при ref_id IS NULL несколько строк с одинаковым
-- (payment_id, kind) НЕ считались бы дублем этим UNIQUE, что и
-- воспроизвелось на проде до первого применения миграции.
UNIQUE NULLS NOT DISTINCT (payment_id, kind, ref_id)
);
COMMENT ON TABLE payment_entitlements IS
'Факт "что выдано за платёж" (доступ), НЕ кредитный кошелёк — amount/'
'consumed сознательно отсутствуют, см. mera-b2c-paid-flow-decision.md §1 '
'(модель кредитов/пакетов отвергнута явно). UNIQUE NULLS NOT DISTINCT '
'(payment_id, kind, ref_id) страхует fulfillment (PR-E) от повторной '
'выдачи по одной нотификации.';
COMMIT;