feat(tradein/payments): схема БД, конфиг и kill-switch платёжного контура
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
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
This commit is contained in:
parent
2a1577738a
commit
277d7e6030
4 changed files with 227 additions and 0 deletions
|
|
@ -918,5 +918,33 @@ class Settings(BaseSettings):
|
|||
# message_thread_id топика внутри support-группы, в который идут зеркала.
|
||||
telegram_support_topic_id: int = Field(default=0, validation_alias="TELEGRAM_SUPPORT_TOPIC_ID")
|
||||
|
||||
# ── Платёжный контур МЕРЫ (Т-Банк эквайринг) — схема-only PR-B ──────────
|
||||
# См. `mera-tbank-acquiring-recon.md` в корне репо. Этот PR НЕ содержит
|
||||
# роутеров/httpx-клиента/подписи Token — только поля конфига и kill-switch.
|
||||
# PAYMENTS_ENABLED=false (дефолт) держит контур выключенным полностью:
|
||||
# ни один из последующих PR (C/D/E) не должен активироваться без явного
|
||||
# включения в .env.runtime прод-стека.
|
||||
tbank_terminal_key: str = Field(default="", validation_alias="TBANK_TERMINAL_KEY")
|
||||
# Пароль терминала — участвует в подписи Token (Init) и проверке подписи
|
||||
# входящих нотификаций. SecretStr по прецеденту auth_db_password (строка
|
||||
# 197 выше): не должен всплыть в логах/repr/Sentry breadcrumbs.
|
||||
tbank_password: SecretStr = Field(default=SecretStr(""), validation_alias="TBANK_PASSWORD")
|
||||
tbank_api_base_url: str = Field(
|
||||
default="https://securepay.tinkoff.ru", validation_alias="TBANK_API_BASE_URL"
|
||||
)
|
||||
tbank_notification_url: str = Field(default="", validation_alias="TBANK_NOTIFICATION_URL")
|
||||
tbank_success_url: str = Field(default="", validation_alias="TBANK_SUCCESS_URL")
|
||||
tbank_fail_url: str = Field(default="", validation_alias="TBANK_FAIL_URL")
|
||||
# "O" — одностадийная (оплата сразу), "T" — двухстадийная (холд + Confirm).
|
||||
# Дефолт "T": выбрана схема с холдом — оставляет возможность ручного шага
|
||||
# между оплатой и выдачей (см. recon-док §2 про компромисс O vs T).
|
||||
tbank_pay_type: Literal["O", "T"] = Field(default="T", validation_alias="TBANK_PAY_TYPE")
|
||||
tbank_receipt_enabled: bool = Field(default=False, validation_alias="TBANK_RECEIPT_ENABLED")
|
||||
tbank_taxation: str = Field(default="", validation_alias="TBANK_TAXATION")
|
||||
tbank_ffd_version: str = Field(default="", validation_alias="TBANK_FFD_VERSION")
|
||||
# Kill-switch всего контура. false — checkout/notify (появятся в PR-D)
|
||||
# обязаны отказывать сразу, ничего не вызывая у T-Bank.
|
||||
payments_enabled: bool = Field(default=False, validation_alias="PAYMENTS_ENABLED")
|
||||
|
||||
|
||||
settings = Settings()
|
||||
|
|
|
|||
|
|
@ -143,6 +143,19 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:
|
|||
get_auth_engine()
|
||||
logger.info("identity_store=auth: DSN общего реестра людей (БД `auth`) сконфигурирован")
|
||||
|
||||
# Платёжный контур МЕРЫ (Т-Банк эквайринг, PR-B — схема-only). Роутер/сервис
|
||||
# ещё не существуют (PR-D), но kill-switch и конфиг уже здесь: если кто-то
|
||||
# включит PAYMENTS_ENABLED=true в .env.runtime раньше, чем заведёт
|
||||
# TBANK_TERMINAL_KEY/TBANK_PASSWORD, лучше не поднимать контейнер вообще,
|
||||
# чем молча остаться без терминала/пароля на боевом трафике оплат.
|
||||
if settings.payments_enabled and (
|
||||
not settings.tbank_terminal_key or not settings.tbank_password.get_secret_value()
|
||||
):
|
||||
raise RuntimeError(
|
||||
"PAYMENTS_ENABLED=true, но TBANK_TERMINAL_KEY/TBANK_PASSWORD пусты — "
|
||||
"задай оба в .env.runtime до включения платёжного контура"
|
||||
)
|
||||
|
||||
# FDW bootstrap: create/refresh USER MAPPING for gendesign_remote postgres_fdw server.
|
||||
# Best-effort: failure does not abort startup, just logs.
|
||||
try:
|
||||
|
|
|
|||
185
tradein-mvp/backend/data/sql/228_payments.sql
Normal file
185
tradein-mvp/backend/data/sql/228_payments.sql
Normal file
|
|
@ -0,0 +1,185 @@
|
|||
-- 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;
|
||||
|
|
@ -230,3 +230,4 @@
|
|||
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
|
||||
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
|
||||
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
|
||||
228_payments.sql
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue