Compare commits

...
Sign in to create a new pull request.

5 commits

Author SHA1 Message Date
bot-backend
95ed9c7811 fix(tradein/payments): номер 232->233 + статусы T-Bank по официальной спеке
All checks were successful
CI / changes (pull_request) Successful in 9s
CI Trade-In / changes (pull_request) Successful in 9s
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 3m10s
Четыре правки 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.
2026-08-06 19:28:44 +03:00
bot-backend
04b268ad3a fix(tradein/payments): разрешить коллизию номера миграции 228 -> 232
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 7s
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 / openapi-codegen-check (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m7s
Пока 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.
2026-08-06 19:07:02 +03:00
bot-backend
31620c7559 Merge remote-tracking branch 'refs/remotes/forgejo/main' into feat/tradein-payments-schema 2026-08-06 19:00:24 +03:00
bot-backend
b8f225bf86 fix(tradein/payments): NULLS NOT DISTINCT дедуп, pd_erased_at, статусы T-Bank
All checks were successful
CI / changes (pull_request) Successful in 8s
CI Trade-In / changes (pull_request) Successful in 10s
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 / browser-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 3m9s
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.
2026-08-06 15:52:44 +03:00
bot-backend
277d7e6030 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
2026-08-06 15:16:20 +03:00
4 changed files with 350 additions and 0 deletions

View file

@ -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()

View file

@ -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:

View file

@ -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/<token>`, волна 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;

View file

@ -230,3 +230,4 @@
# Тем самым снято отложенное условие из прошлой редакции: 187/188 (веб-чат
# поддержки, #2532/#2533) откладывались до подтверждения, что они осели на
# проде в финальном виде. Они в _schema_migrations — условие выполнено.
233_payments.sql