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
185 lines
12 KiB
PL/PgSQL
185 lines
12 KiB
PL/PgSQL
-- 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;
|