diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 337cd886..5d7267aa 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -918,5 +918,36 @@ 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": выбрана схема с холдом (гибрид «Проба → холд → отчёт по + # ссылке», ядро — вариант B) — источник решения `mera-b2c-paid-flow- + # decision.md` §1 в корне репо, НЕ recon-док (тот сам по себе выбирает + # "O" — устарел этим решением). Не переставляй дефолт обратно на "O", не + # сверившись с decision-доком. + 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/233_payments.sql b/tradein-mvp/backend/data/sql/233_payments.sql new file mode 100644 index 00000000..99dfeca9 --- /dev/null +++ b/tradein-mvp/backend/data/sql/233_payments.sql @@ -0,0 +1,305 @@ +-- 233_payments.sql +-- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии +-- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). +-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на +-- месте по итогам review (статус HOLD), без ребейза номера. Дважды +-- переименована (git mv, история сохранена): 228 → 232 → 233. Номер 228 +-- заняли 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql +-- (влились в main); 229 и 231 занимает открытый PR #2547; 232 занял открытый +-- PR #2742 (`232_listings_observation_time_meaning.sql`). Урок: сверять номер +-- нужно не только по `forgejo/main`, но и по ВСЕМ открытым PR-веткам — ни один +-- из этих файлов сам себя в `_manifest_applied.txt` не пишет (мы пишем), из-за +-- чего коллизия обнаруживается только тестом `test_new_files_do_not_reuse_prefix` +-- уже после того, как чей-то PR смержен первым. +-- +-- ── 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) ────────────────────────────────── +-- Источник истины — официальная OpenAPI-спека: +-- https://developer.tbank.ru/schemas/eacq/openapi.yaml (OpenAPI 3.0.2, v1.24), +-- схема `Confirm-2`, 24 значения. На странице /eacq/intro/developer/openapi +-- прямо сказано: при расхождении прозы и спеки приоритет у спеки — поэтому +-- ссылка на спеку, а не на человекочитаемые доки. +-- +-- ВАЖНО про GetState/CheckOrder (ручки, которыми реконсиляция PR-E читает +-- статус): в спеке их поле `Status` объявлено СВОБОДНОЙ строкой +-- (`maxLength: 20`, БЕЗ enum). То есть на ручках, которыми фактически питается +-- реконсиляция, банк словарь значений не фиксирует контрактно — наш CHECK +-- здесь строже, чем контракт поставщика. Это осознанный выбор (закрытый +-- список читается и валидируется проще, чем произвольная строка), а не +-- недосмотр; следующий читатель должен видеть, что этот CHECK может однажды +-- отвергнуть легитимный, но недокументированный `Confirm-2`-строкой статус — +-- см. контракт 'UNKNOWN' ниже. +-- +-- Сверка по `Confirm-2` относительно первой версии миграции: +-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — отсутствуют в +-- `Confirm-2`. Дополнительно у поля `Status` в спеке `maxLength: 20`, а +-- 'AUTHORIZED_AND_CHARGED' — 22 символа: физически не может быть значением +-- этого поля, не только "не найдено", а невозможно по контракту. +-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — есть в `Confirm-2`. +-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' — отсутствуют в +-- `Confirm-2`, гипотеза не подтвердилась. +-- - 'PREAUTHORIZING' оставлен и подтверждён: есть в `Confirm-2`. (Ранее +-- редакция ссылалась на комментарий стороннего Go-клиента о том, что этот +-- статус будто бы убран из API — спекой это не подтверждается, комментарий +-- был неточным источником и снят.) +-- - ДОБАВЛЕНЫ пять пропущенных in-flight значений из `Confirm-2`: +-- 'CHECKING', 'CHECKED', 'PROCESSING', 'COMPLETING', 'COMPLETED'. Это +-- ровно те статусы, которые GetState/CheckOrder вернёт по зависшему +-- платежу — их читает реконсиляция (PR-E). Пропуск реального значения — +-- единственное опасное направление ошибки CHECK: не "лишний" статус +-- проскочит, а свой же CHECK отвергнет то, что банк реально прислал. +-- Порядок в списке ниже — по смысловой близости к соседним стадиям +-- (CHECKING/CHECKED рядом с 3DS_CHECKING/3DS_CHECKED, COMPLETING/COMPLETED +-- рядом с CONFIRMED), а не порядок из спеки — `Confirm-2` не гарантирует +-- порядок enum, для CHECK-констрейнта (проверка принадлежности множеству) +-- порядок значения не имеет. +-- - 'PARTIAL_REVERSED' и 'REFUND_FAILED' в `Confirm-2` ОТСУТСТВУЮТ. Оставлены +-- в CHECK как безвредный запас на случай появления в будущей версии API +-- (сам факт лишнего разрешённого значения в CHECK ничего не ломает — в +-- отличие от отсутствующего). Это отличается от предыдущей редакции +-- комментария, которая ошибочно утверждала, что сверка их "подтверждает": +-- не подтверждает, они не найдены в источнике истины. +-- +-- Контракт: если банк присылает статус вне списка ниже, ОБРАБОТЧИК +-- НОТИФИКАЦИЙ И ЗАДАЧА РЕКОНСИЛЯЦИИ (GetState/CheckOrder, PR-E) обязаны +-- писать в payments.status значение 'UNKNOWN' (не поднимать исключение, не +-- терять запись) — сырое тело нотификации в любом случае лежит целиком в +-- payment_notifications.body (у GetState/CheckOrder своего append-only лога +-- нет — если реконсиляция сама не сохранит сырой ответ, факт неизвестного +-- статуса останется только в payments.status='UNKNOWN' и её собственных логах). +-- INSERT/UPDATE payments с любым другим незнакомым значением упадёт на +-- CHECK — это специально: тихое искажение статуса хуже, чем громкий сбой +-- одной записи. +-- +-- ── payment_entitlements: без кредитно-кошельковой семантики ──────────────── +-- Первая версия несла amount/consumed (модель «кредиты/пакеты»). Явно +-- отвергнуто в `mera-b2c-paid-flow-decision.md` (§1): «Что отвергнуто явно: +-- кредиты/пакеты, роль customer, ... — цена ошибки в guard'е выше годовой +-- выручки этой воронки». Доставка купленного выбрана через capability-URL +-- (`/r/`, волна 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: 230_house_merge_log.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', + 'CHECKING', + 'CHECKED', + 'PROCESSING', + 'CONFIRMING', + 'CONFIRMED', + 'COMPLETING', + 'COMPLETED', + '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; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 0fb41dd0..7d71c6d7 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 — условие выполнено. +233_payments.sql