gendesign/tradein-mvp/backend/data/sql/228_payments.sql
bot-backend 277d7e6030
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI Trade-In / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
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 3m5s
feat(tradein/payments): схема БД, конфиг и kill-switch платёжного контура
2026-08-06 15:16:20 +03:00

185 lines
12 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.

-- 228_payments.sql
-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии
-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»).
--
-- ── WHY ──────────────────────────────────────────────────────────────────────
-- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в
-- app/core/config.py, тот же PR). Роутера, httpx-клиента Т-Банка, подписи
-- Token, статус-машины и обработчика нотификаций здесь НЕТ — они появятся в
-- PR-C/D/E. До PAYMENTS_ENABLED=true эти три таблицы просто не пишутся никаким
-- кодом; создание сейчас разблокирует параллельную разработку PR-C/D без
-- гонки миграций.
--
-- ── WHAT ─────────────────────────────────────────────────────────────────────
-- payments — одна строка на попытку оплаты (Init → notify →
-- Confirm/Cancel). order_id — наш внутренний id,
-- уходит в T-Bank как OrderId (≤50 симв., см. §3
-- recon-дока); tbank_payment_id — PaymentId из
-- ответа Init, известен только ПОСЛЕ вызова.
-- payment_notifications — append-only лог входящих вебхуков Т-Банка.
-- Идемпотентность нотификаций — это и есть
-- UNIQUE(tbank_payment_id, status, amount_kopecks,
-- token): T-Bank шлёт AUTHORIZED и CONFIRMED
-- одновременно, дедуп через ON CONFLICT DO NOTHING
-- (сервисный код — PR-D). Осознанно БЕЗ CHECK на
-- status: это сырой лог входящих данных, узкий CHECK
-- здесь означал бы, что недокументированный/новый
-- статус банка ломает запись самого факта нотификации.
-- payment_entitlements — что выдано за платёж (кредит/доступ), чтобы
-- fulfillment (PR-E) не задваивал выдачу.
--
-- ── СТАТУСЫ T-BANK (payments.status CHECK) ──────────────────────────────────
-- Список — публичный Status-enum платёжного объекта T-Bank Acquiring API
-- (Init/GetState/CheckOrder). Recon §11 «Непроверенное» отдельно фиксирует:
-- PARTIAL_REVERSED фигурирует в сценарии отмены, но описание enum в самой
-- документации банка внутренне противоречиво — оставлен в списке нарочно
-- (не блокировать легитимный переход), а не изобретён нами.
-- Если прод когда-нибудь получит статус вне списка — упадёт INSERT/UPDATE в
-- payments (не в payment_notifications, туда попадёт всё равно) и это будет
-- сигналом расширить CHECK отдельной миграцией, а не тихим искажением данных.
--
-- ── 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, -- наш id, -> T-Bank OrderId (<=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,
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',
'CONFIRMING',
'CONFIRMED',
'REVERSING',
'PARTIAL_REVERSED',
'REVERSED',
'REFUNDING',
'PARTIAL_REFUNDED',
'REFUNDED',
'REFUND_FAILED',
'RECEIPT_REGISTERED',
'AUTHORIZED_AND_CHARGED',
'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);
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(),
-- Дедуп-ключ идемпотентности (recon §3 п.4): T-Bank шлёт AUTHORIZED и
-- CONFIRMED одновременно для одностадийной оплаты; ON CONFLICT DO NOTHING
-- в сервисном коде (PR-D) значит "уже обработано". NB: NULL в Postgres не
-- равен NULL — несколько строк с одинаковым (NULL, ...) НЕ схлопнутся этим
-- UNIQUE. На практике token де-факто заполнен всегда (иначе подпись не
-- проверить), поэтому дыра теоретическая, но сервисный слой не должен
-- полагаться на UNIQUE как единственную защиту при token IS NULL.
UNIQUE (tbank_payment_id, status, amount_kopecks, token)
);
COMMENT ON TABLE payment_notifications IS
'Append-only лог входящих вебхуков T-Bank. Идемпотентность через UNIQUE '
'(tbank_payment_id, status, amount_kopecks, token) + ON CONFLICT DO NOTHING.';
-- ─────────────────────────────────────────────────────────────────────────
-- 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' | 'estimate_pack' | ...
ref_id uuid, -- estimate_id для разового отчёта, NULL для пакетов
amount int NOT NULL DEFAULT 1,
consumed int NOT NULL DEFAULT 0,
expires_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
-- Гарантия "выдали один раз" (recon §3). Та же NULL-оговорка, что и выше:
-- при ref_id IS NULL (напр. kind='estimate_pack') несколько строк с
-- одинаковым (payment_id, kind) НЕ считаются дублем этим UNIQUE —
-- сервисный слой (PR-E) обязан сам гарантировать один INSERT на платёж
-- там, где ref_id не используется как различитель.
UNIQUE (payment_id, kind, ref_id)
);
COMMENT ON TABLE payment_entitlements IS
'Что выдано за платёж (доступ/кредит). UNIQUE(payment_id, kind, ref_id) '
'страхует fulfillment (PR-E) от повторной выдачи по одной нотификации.';
COMMIT;