-- 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;