All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
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 / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m9s
HOLD-правки ревью PR #2732 (миграция ни разу не применялась на проде, manifest не трогаем): - Блокер: UNIQUE(payment_id, kind, ref_id) и UNIQUE(tbank_payment_id, status, amount_kopecks, token) не защищали при NULL (NULL != NULL в Postgres) — на проде воспроизведено 3 одинаковых INSERT -> 3 строки. Оба ключа теперь UNIQUE NULLS NOT DISTINCT (PG15+, прод на 16.4). - payment_notifications.processed_at — контракт "выдача состоялась" для PR-D, закрывает окно at-most-once (падение между INSERT нотификации и выдачей товара при захолдированных деньгах). - payments_lead_idx — FK lead_id ON DELETE SET NULL без индекса это seq scan на каждый DELETE FROM trade_in_leads, а #2547 вводит пакетное удаление. - payment_entitlements: убраны amount/consumed — модель кредитов/пакетов явно отвергнута в mera-b2c-paid-flow-decision.md §1 (capability-URL вместо кошелька). - payments.pd_erased_at — покрытие purge-контура #2547, который не знает про ПДн в payments (customer_email/customer_phone), появляющиеся в PR-D. - payments.order_id CHECK char_length <= 50 — лимит T-Bank OrderId, падать у себя, а не на /v2/Init. - payments.status CHECK сверен с github.com/nikita-vanyasin/tinkoff/status.go (developer.tbank.ru рендерит enum клиентским JS, прямого доступа нет): убраны неподтверждённые AUTHORIZED_AND_CHARGED/RECEIPT_REGISTERED, добавлены подтверждённые 3DS_CHECKING/3DS_CHECKED. ATTEMPTS_EXPIRED/PAY_CHECKING НЕ добавлены — не подтверждены ни одним источником. - config.py: комментарий TBANK_PAY_TYPE ссылался на recon-док (выбирает "O"), переставлен на mera-b2c-paid-flow-decision.md §1 (выбирает "T", источник реального дефолта). Полный pytest: 3755 passed, 9 skipped, 0 failed.
262 lines
21 KiB
PL/PgSQL
262 lines
21 KiB
PL/PgSQL
-- 228_payments.sql
|
||
-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии
|
||
-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»).
|
||
-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на
|
||
-- месте по итогам review (статус HOLD), без ребейза номера.
|
||
--
|
||
-- ── 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) ──────────────────────────────────
|
||
-- Список сверен с публичным Status-enum T-Bank Acquiring API. Источник
|
||
-- developer.tbank.ru рендерит enum клиентским JS (правая панель схемы ответа
|
||
-- динамически подгружается) — прямого текстового доступа к разделу GetState
|
||
-- не получено; сверка выполнена по независимому активно поддерживаемому
|
||
-- Go-клиенту (github.com/nikita-vanyasin/tinkoff, файл status.go), который
|
||
-- явно комментирует каждый статус. Изменения относительно первой версии:
|
||
-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — не найдены ни
|
||
-- в одном сверенном источнике; RECEIPT_REGISTERED похоже на статус
|
||
-- отдельного объекта «чек» (SendClosingReceipt), не платежа.
|
||
-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — подтверждены сверкой.
|
||
-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' (гипотеза из ревью) —
|
||
-- не нашлись ни в одном источнике, которым удалось свериться; если реально
|
||
-- существуют — расширить CHECK отдельной миграцией по факту документа.
|
||
-- - 'PARTIAL_REVERSED' оставлен: та же сверка его подтверждает (реально
|
||
-- существующий статус частичной отмены холда), а не «оставлен из
|
||
-- осторожности», как было сформулировано раньше.
|
||
-- - 'PREAUTHORIZING' оставлен как есть (не проверялся под вопрос ревью):
|
||
-- тот же Go-клиент помечает его комментарием "deprecated / removed from
|
||
-- API", но это не запрошенная часть проверки — трогать не стал, инертное
|
||
-- значение в CHECK безвредно, если банк его больше не шлёт.
|
||
-- Контракт для PR-D: если банк присылает статус вне списка ниже, обработчик
|
||
-- нотификаций обязан писать в payments.status значение 'UNKNOWN' (не поднимать
|
||
-- исключение, не терять запись) — сырое тело в любом случае лежит целиком в
|
||
-- payment_notifications.body. 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: 227_drop_position_in_serp.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',
|
||
'CONFIRMING',
|
||
'CONFIRMED',
|
||
'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;
|