From 277d7e6030025133df427321d451eb40bf27db4a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:16:20 +0300 Subject: [PATCH] =?UTF-8?q?feat(tradein/payments):=20=D1=81=D1=85=D0=B5?= =?UTF-8?q?=D0=BC=D0=B0=20=D0=91=D0=94,=20=D0=BA=D0=BE=D0=BD=D1=84=D0=B8?= =?UTF-8?q?=D0=B3=20=D0=B8=20kill-switch=20=D0=BF=D0=BB=D0=B0=D1=82=D1=91?= =?UTF-8?q?=D0=B6=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82=D1=83?= =?UTF-8?q?=D1=80=D0=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tradein-mvp/backend/app/core/config.py | 28 +++ tradein-mvp/backend/app/main.py | 13 ++ tradein-mvp/backend/data/sql/228_payments.sql | 185 ++++++++++++++++++ .../backend/data/sql/_manifest_applied.txt | 1 + 4 files changed, 227 insertions(+) create mode 100644 tradein-mvp/backend/data/sql/228_payments.sql diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 337cd886..609729e1 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -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() diff --git a/tradein-mvp/backend/app/main.py b/tradein-mvp/backend/app/main.py index bea049b8..7688fe0b 100644 --- a/tradein-mvp/backend/app/main.py +++ b/tradein-mvp/backend/app/main.py @@ -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: diff --git a/tradein-mvp/backend/data/sql/228_payments.sql b/tradein-mvp/backend/data/sql/228_payments.sql new file mode 100644 index 00000000..97c87b77 --- /dev/null +++ b/tradein-mvp/backend/data/sql/228_payments.sql @@ -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; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 0fb41dd0..c0907965 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,3 +230,4 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. +228_payments.sql