From 277d7e6030025133df427321d451eb40bf27db4a Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:16:20 +0300 Subject: [PATCH 1/4] =?UTF-8?q?feat(tradein/payments):=20=D1=81=D1=85?= =?UTF-8?q?=D0=B5=D0=BC=D0=B0=20=D0=91=D0=94,=20=D0=BA=D0=BE=D0=BD=D1=84?= =?UTF-8?q?=D0=B8=D0=B3=20=D0=B8=20kill-switch=20=D0=BF=D0=BB=D0=B0=D1=82?= =?UTF-8?q?=D1=91=D0=B6=D0=BD=D0=BE=D0=B3=D0=BE=20=D0=BA=D0=BE=D0=BD=D1=82?= =?UTF-8?q?=D1=83=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 -- 2.45.3 From b8f225bf86692c5f6916a023b9e51f2e668c9e03 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 15:52:44 +0300 Subject: [PATCH 2/4] =?UTF-8?q?fix(tradein/payments):=20NULLS=20NOT=20DIST?= =?UTF-8?q?INCT=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF,=20pd=5Ferased=5Fat,=20?= =?UTF-8?q?=D1=81=D1=82=D0=B0=D1=82=D1=83=D1=81=D1=8B=20T-Bank?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HOLD-правки ревью PR #2732 (миграция ни разу не применялась на проде, manifest не трогаем): - Блокер: UNIQUE(payment_id, kind, ref_id) и UNIQUE(tbank_payment_id, status, amount_kopecks, token) не защищали при NULL (NULL != NULL в Postgres) — на проде воспроизведено 3 одинаковых INSERT -> 3 строки. Оба ключа теперь UNIQUE NULLS NOT DISTINCT (PG15+, прод на 16.4). - payment_notifications.processed_at — контракт "выдача состоялась" для PR-D, закрывает окно at-most-once (падение между INSERT нотификации и выдачей товара при захолдированных деньгах). - payments_lead_idx — FK lead_id ON DELETE SET NULL без индекса это seq scan на каждый DELETE FROM trade_in_leads, а #2547 вводит пакетное удаление. - payment_entitlements: убраны amount/consumed — модель кредитов/пакетов явно отвергнута в mera-b2c-paid-flow-decision.md §1 (capability-URL вместо кошелька). - payments.pd_erased_at — покрытие purge-контура #2547, который не знает про ПДн в payments (customer_email/customer_phone), появляющиеся в PR-D. - payments.order_id CHECK char_length <= 50 — лимит T-Bank OrderId, падать у себя, а не на /v2/Init. - payments.status CHECK сверен с github.com/nikita-vanyasin/tinkoff/status.go (developer.tbank.ru рендерит enum клиентским JS, прямого доступа нет): убраны неподтверждённые AUTHORIZED_AND_CHARGED/RECEIPT_REGISTERED, добавлены подтверждённые 3DS_CHECKING/3DS_CHECKED. ATTEMPTS_EXPIRED/PAY_CHECKING НЕ добавлены — не подтверждены ни одним источником. - config.py: комментарий TBANK_PAY_TYPE ссылался на recon-док (выбирает "O"), переставлен на mera-b2c-paid-flow-decision.md §1 (выбирает "T", источник реального дефолта). Полный pytest: 3755 passed, 9 skipped, 0 failed. --- tradein-mvp/backend/app/core/config.py | 7 +- tradein-mvp/backend/data/sql/228_payments.sql | 173 +++++++++++++----- 2 files changed, 130 insertions(+), 50 deletions(-) diff --git a/tradein-mvp/backend/app/core/config.py b/tradein-mvp/backend/app/core/config.py index 609729e1..5d7267aa 100644 --- a/tradein-mvp/backend/app/core/config.py +++ b/tradein-mvp/backend/app/core/config.py @@ -936,8 +936,11 @@ class Settings(BaseSettings): 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). + # Дефолт "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") diff --git a/tradein-mvp/backend/data/sql/228_payments.sql b/tradein-mvp/backend/data/sql/228_payments.sql index 97c87b77..cddc3389 100644 --- a/tradein-mvp/backend/data/sql/228_payments.sql +++ b/tradein-mvp/backend/data/sql/228_payments.sql @@ -1,42 +1,109 @@ -- 228_payments.sql -- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии -- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). +-- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на +-- месте по итогам review (статус HOLD), без ребейза номера. -- -- ── WHY ────────────────────────────────────────────────────────────────────── -- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в --- app/core/config.py, тот же PR). Роутера, httpx-клиента Т-Банка, подписи --- Token, статус-машины и обработчика нотификаций здесь НЕТ — они появятся в --- PR-C/D/E. До PAYMENTS_ENABLED=true эти три таблицы просто не пишутся никаким --- кодом; создание сейчас разблокирует параллельную разработку PR-C/D без --- гонки миграций. +-- 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 (≤50 симв., см. §3 --- recon-дока); tbank_payment_id — PaymentId из --- ответа Init, известен только ПОСЛЕ вызова. +-- уходит в T-Bank как OrderId (CHECK ≤50 симв. — +-- падать у себя, а не на /v2/Init); tbank_payment_id — +-- PaymentId из ответа Init, известен только ПОСЛЕ +-- вызова. pd_erased_at — см. отдельный блок ниже. -- 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) не задваивал выдачу. +-- 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) ────────────────────────────────── --- Список — публичный Status-enum платёжного объекта T-Bank Acquiring API --- (Init/GetState/CheckOrder). Recon §11 «Непроверенное» отдельно фиксирует: --- PARTIAL_REVERSED фигурирует в сценарии отмены, но описание enum в самой --- документации банка внутренне противоречиво — оставлен в списке нарочно --- (не блокировать легитимный переход), а не изобретён нами. --- Если прод когда-нибудь получит статус вне списка — упадёт INSERT/UPDATE в --- payments (не в payment_notifications, туда попадёт всё равно) и это будет --- сигналом расширить CHECK отдельной миграцией, а не тихим искажением данных. +-- Список сверен с публичным Status-enum T-Bank Acquiring API. Источник +-- developer.tbank.ru рендерит enum клиентским JS (правая панель схемы ответа +-- динамически подгружается) — прямого текстового доступа к разделу GetState +-- не получено; сверка выполнена по независимому активно поддерживаемому +-- Go-клиенту (github.com/nikita-vanyasin/tinkoff, файл status.go), который +-- явно комментирует каждый статус. Изменения относительно первой версии: +-- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — не найдены ни +-- в одном сверенном источнике; RECEIPT_REGISTERED похоже на статус +-- отдельного объекта «чек» (SendClosingReceipt), не платежа. +-- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — подтверждены сверкой. +-- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' (гипотеза из ревью) — +-- не нашлись ни в одном источнике, которым удалось свериться; если реально +-- существуют — расширить CHECK отдельной миграцией по факту документа. +-- - 'PARTIAL_REVERSED' оставлен: та же сверка его подтверждает (реально +-- существующий статус частичной отмены холда), а не «оставлен из +-- осторожности», как было сформулировано раньше. +-- - 'PREAUTHORIZING' оставлен как есть (не проверялся под вопрос ревью): +-- тот же Go-клиент помечает его комментарием "deprecated / removed from +-- API", но это не запрошенная часть проверки — трогать не стал, инертное +-- значение в CHECK безвредно, если банк его больше не шлёт. +-- Контракт для PR-D: если банк присылает статус вне списка ниже, обработчик +-- нотификаций обязан писать в payments.status значение 'UNKNOWN' (не поднимать +-- исключение, не терять запись) — сырое тело в любом случае лежит целиком в +-- payment_notifications.body. 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 @@ -61,7 +128,7 @@ BEGIN; 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 симв.) + 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, не цена из тела запроса) @@ -75,6 +142,7 @@ CREATE TABLE IF NOT EXISTS payments ( 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, @@ -100,6 +168,8 @@ ALTER TABLE payments 'AUTHORIZED', 'AUTH_FAIL', 'REJECTED', + '3DS_CHECKING', + '3DS_CHECKED', 'CONFIRMING', 'CONFIRMED', 'REVERSING', @@ -109,14 +179,17 @@ ALTER TABLE payments '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); +-- 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 'Платёжный контур МЕРЫ (Т-Банк эквайринг). Одна строка на попытку оплаты. ' @@ -138,48 +211,52 @@ CREATE TABLE IF NOT EXISTS payment_notifications ( 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) значит "уже обработано". NB: NULL в Postgres не - -- равен NULL — несколько строк с одинаковым (NULL, ...) НЕ схлопнутся этим - -- UNIQUE. На практике token де-факто заполнен всегда (иначе подпись не - -- проверить), поэтому дыра теоретическая, но сервисный слой не должен - -- полагаться на UNIQUE как единственную защиту при token IS NULL. - UNIQUE (tbank_payment_id, status, amount_kopecks, token) + -- в сервисном коде (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 ' - '(tbank_payment_id, status, amount_kopecks, token) + ON CONFLICT DO NOTHING.'; + 'NULLS NOT DISTINCT(tbank_payment_id, status, amount_kopecks, token) + ' + 'ON CONFLICT DO NOTHING. processed_at — контракт "выдача состоялась" для PR-D.'; -- ───────────────────────────────────────────────────────────────────────── --- payment_entitlements — что выдано за платёж +-- 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 для пакетов + kind text NOT NULL, -- 'pdf_report' | 'report_link' | ... + 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) + -- Гарантия "выдали один раз" (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 - 'Что выдано за платёж (доступ/кредит). UNIQUE(payment_id, kind, ref_id) ' - 'страхует fulfillment (PR-E) от повторной выдачи по одной нотификации.'; + 'Факт "что выдано за платёж" (доступ), НЕ кредитный кошелёк — amount/' + 'consumed сознательно отсутствуют, см. mera-b2c-paid-flow-decision.md §1 ' + '(модель кредитов/пакетов отвергнута явно). UNIQUE NULLS NOT DISTINCT ' + '(payment_id, kind, ref_id) страхует fulfillment (PR-E) от повторной ' + 'выдачи по одной нотификации.'; COMMIT; -- 2.45.3 From 04b268ad3a9ad2165444e3367464e8d849741247 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 19:07:02 +0300 Subject: [PATCH 3/4] =?UTF-8?q?fix(tradein/payments):=20=D1=80=D0=B0=D0=B7?= =?UTF-8?q?=D1=80=D0=B5=D1=88=D0=B8=D1=82=D1=8C=20=D0=BA=D0=BE=D0=BB=D0=BB?= =?UTF-8?q?=D0=B8=D0=B7=D0=B8=D1=8E=20=D0=BD=D0=BE=D0=BC=D0=B5=D1=80=D0=B0?= =?UTF-8?q?=20=D0=BC=D0=B8=D0=B3=D1=80=D0=B0=D1=86=D0=B8=D0=B8=20228=20->?= =?UTF-8?q?=20232?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Пока PR был на ревью, в main влились 228_scrape_proxies_browser_health.sql и 230_house_merge_log.sql — номер 228 занят, 229/231 параллельно занимает PR #2547. Переименовано в 232_payments.sql (git mv, история файла сохранена), manifest обновлён (228_payments.sql -> 232_payments.sql), шапка миграции объясняет причину переименования и актуализирован "Apply after". Смержен свежий forgejo/main (обычный merge, без rebase/force-push) — конфликтов не было, config.py не задет. test_new_files_do_not_reuse_prefix теперь зелёный. Полный pytest после merge: 3858 passed, 10 skipped, 0 failed. --- .../data/sql/{228_payments.sql => 232_payments.sql} | 9 ++++++--- tradein-mvp/backend/data/sql/_manifest_applied.txt | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) rename tradein-mvp/backend/data/sql/{228_payments.sql => 232_payments.sql} (97%) diff --git a/tradein-mvp/backend/data/sql/228_payments.sql b/tradein-mvp/backend/data/sql/232_payments.sql similarity index 97% rename from tradein-mvp/backend/data/sql/228_payments.sql rename to tradein-mvp/backend/data/sql/232_payments.sql index cddc3389..affc6882 100644 --- a/tradein-mvp/backend/data/sql/228_payments.sql +++ b/tradein-mvp/backend/data/sql/232_payments.sql @@ -1,8 +1,11 @@ --- 228_payments.sql +-- 232_payments.sql -- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии -- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). -- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на --- месте по итогам review (статус HOLD), без ребейза номера. +-- месте по итогам review (статус HOLD), без ребейза номера. Переименована из +-- 228_payments.sql в 232_payments.sql (git mv, история сохранена): номер 228 +-- заняли 228_scrape_proxies_browser_health.sql, 230 — 230_house_merge_log.sql +-- (влились в main, пока шло ревью); 229 и 231 параллельно занимает PR #2547. -- -- ── WHY ────────────────────────────────────────────────────────────────────── -- Этот PR — ТОЛЬКО схема + конфиг + kill-switch (`PAYMENTS_ENABLED=false` в @@ -118,7 +121,7 @@ -- estimate/lead не должно ронять запись о платеже. -- -- Dependencies: 001_trade_in_estimates.sql, 172_trade_in_leads.sql. --- Apply after: 227_drop_position_in_serp.sql. +-- Apply after: 230_house_merge_log.sql. BEGIN; diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index c0907965..75c13875 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,4 +230,4 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. -228_payments.sql +232_payments.sql -- 2.45.3 From 95ed9c7811233035060bbd76ad067b144f421f03 Mon Sep 17 00:00:00 2001 From: bot-backend Date: Thu, 6 Aug 2026 19:28:44 +0300 Subject: [PATCH 4/4] =?UTF-8?q?fix(tradein/payments):=20=D0=BD=D0=BE=D0=BC?= =?UTF-8?q?=D0=B5=D1=80=20232->233=20+=20=D1=81=D1=82=D0=B0=D1=82=D1=83?= =?UTF-8?q?=D1=81=D1=8B=20T-Bank=20=D0=BF=D0=BE=20=D0=BE=D1=84=D0=B8=D1=86?= =?UTF-8?q?=D0=B8=D0=B0=D0=BB=D1=8C=D0=BD=D0=BE=D0=B9=20=D1=81=D0=BF=D0=B5?= =?UTF-8?q?=D0=BA=D0=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Четыре правки HOLD-ревью PR #2732, форма таблиц не меняется: 1. Коллизия номера: 232_listings_observation_time_meaning.sql несёт открытый PR #2742 (не main, поэтому пропущено в прошлый раз). Переименовано 232 -> 233 (git mv, история сохранена), manifest обновлён. Урок задокументирован в шапке файла: сверять номер нужно по main И по всем открытым PR-веткам. Перепроверено дважды (main + 5 остальных открытых веток) — 233 свободен. 2. payments_status_check дополнен пятью реальными in-flight статусами из канонического источника (OpenAPI-спека developer.tbank.ru/schemas/eacq/ openapi.yaml, схема Confirm-2, v1.24): CHECKING, CHECKED, PROCESSING, COMPLETING, COMPLETED. Это статусы, которые GetState/CheckOrder вернёт по зависшему платежу — их читает реконсиляция PR-E; пропуск реального значения был единственным опасным направлением ошибки CHECK. 3. Блок про источник статусов переписан: убрана ссылка на сторонний Go-клиент и формулировка "enum рендерится клиентским JS" — источник истины теперь openapi.yaml. Отдельно зафиксировано: GetState/CheckOrder объявляют Status свободной строкой (maxLength: 20, без enum) — наш CHECK строже контракта поставщика, осознанно. 4. Контракт 'UNKNOWN' переадресован: теперь явно на обработчик нотификаций И задачу реконсиляции (GetState/CheckOrder, PR-E), а не только на вебхуки. Побочно подтверждено спекой: AUTHORIZED_AND_CHARGED/RECEIPT_REGISTERED отсутствуют (плюс maxLength:20 делает первое физически невозможным); 3DS_CHECKING/3DS_CHECKED и PREAUTHORIZING есть; ATTEMPTS_EXPIRED/PAY_CHECKING нет; PARTIAL_REVERSED/REFUND_FAILED тоже нет в спеке — оставлены как безвредный запас, комментарий перестал ложно утверждать обратное. Полный pytest: 3858 passed, 10 skipped, 0 failed. --- .../{232_payments.sql => 233_payments.sql} | 102 ++++++++++++------ .../backend/data/sql/_manifest_applied.txt | 2 +- 2 files changed, 72 insertions(+), 32 deletions(-) rename tradein-mvp/backend/data/sql/{232_payments.sql => 233_payments.sql} (73%) diff --git a/tradein-mvp/backend/data/sql/232_payments.sql b/tradein-mvp/backend/data/sql/233_payments.sql similarity index 73% rename from tradein-mvp/backend/data/sql/232_payments.sql rename to tradein-mvp/backend/data/sql/233_payments.sql index affc6882..99dfeca9 100644 --- a/tradein-mvp/backend/data/sql/232_payments.sql +++ b/tradein-mvp/backend/data/sql/233_payments.sql @@ -1,11 +1,16 @@ --- 232_payments.sql +-- 233_payments.sql -- Платёжный контур МЕРЫ (Т-Банк интернет-эквайринг) — схема БД, PR-B из серии -- A..F (см. корень репо `mera-tbank-acquiring-recon.md`, §9 «Разбивка на PR»). -- Ни разу не применялась на проде (см. `_manifest_applied.txt`) — правится на --- месте по итогам review (статус HOLD), без ребейза номера. Переименована из --- 228_payments.sql в 232_payments.sql (git mv, история сохранена): номер 228 --- заняли 228_scrape_proxies_browser_health.sql, 230 — 230_house_merge_log.sql --- (влились в main, пока шло ревью); 229 и 231 параллельно занимает PR #2547. +-- месте по итогам 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` в @@ -49,32 +54,62 @@ -- DO UPDATE). Применено к обоим дедуп-ключам ниже. -- -- ── СТАТУСЫ T-BANK (payments.status CHECK) ────────────────────────────────── --- Список сверен с публичным Status-enum T-Bank Acquiring API. Источник --- developer.tbank.ru рендерит enum клиентским JS (правая панель схемы ответа --- динамически подгружается) — прямого текстового доступа к разделу GetState --- не получено; сверка выполнена по независимому активно поддерживаемому --- Go-клиенту (github.com/nikita-vanyasin/tinkoff, файл status.go), который --- явно комментирует каждый статус. Изменения относительно первой версии: --- - УБРАНЫ 'AUTHORIZED_AND_CHARGED' и 'RECEIPT_REGISTERED' — не найдены ни --- в одном сверенном источнике; RECEIPT_REGISTERED похоже на статус --- отдельного объекта «чек» (SendClosingReceipt), не платежа. --- - ДОБАВЛЕНЫ '3DS_CHECKING' и '3DS_CHECKED' — подтверждены сверкой. --- - НЕ добавлены 'ATTEMPTS_EXPIRED' и 'PAY_CHECKING' (гипотеза из ревью) — --- не нашлись ни в одном источнике, которым удалось свериться; если реально --- существуют — расширить CHECK отдельной миграцией по факту документа. --- - 'PARTIAL_REVERSED' оставлен: та же сверка его подтверждает (реально --- существующий статус частичной отмены холда), а не «оставлен из --- осторожности», как было сформулировано раньше. --- - 'PREAUTHORIZING' оставлен как есть (не проверялся под вопрос ревью): --- тот же Go-клиент помечает его комментарием "deprecated / removed from --- API", но это не запрошенная часть проверки — трогать не стал, инертное --- значение в CHECK безвредно, если банк его больше не шлёт. --- Контракт для PR-D: если банк присылает статус вне списка ниже, обработчик --- нотификаций обязан писать в payments.status значение 'UNKNOWN' (не поднимать --- исключение, не терять запись) — сырое тело в любом случае лежит целиком в --- payment_notifications.body. INSERT/UPDATE payments с любым другим --- незнакомым значением упадёт на 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 (модель «кредиты/пакеты»). Явно @@ -173,8 +208,13 @@ ALTER TABLE payments 'REJECTED', '3DS_CHECKING', '3DS_CHECKED', + 'CHECKING', + 'CHECKED', + 'PROCESSING', 'CONFIRMING', 'CONFIRMED', + 'COMPLETING', + 'COMPLETED', 'REVERSING', 'PARTIAL_REVERSED', 'REVERSED', diff --git a/tradein-mvp/backend/data/sql/_manifest_applied.txt b/tradein-mvp/backend/data/sql/_manifest_applied.txt index 75c13875..7d71c6d7 100644 --- a/tradein-mvp/backend/data/sql/_manifest_applied.txt +++ b/tradein-mvp/backend/data/sql/_manifest_applied.txt @@ -230,4 +230,4 @@ # Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат # поддержки, #2532/#2533) откладывались до подтверждения, что они осели на # проде в финальном виде. Они в _schema_migrations — условие выполнено. -232_payments.sql +233_payments.sql -- 2.45.3