feat(mera/b2c): платёжный роутер, статус-машина и доставка купленного (за флагом) #3231

Merged
bot-backend merged 3 commits from feat/b2c-payments-router into main 2026-08-29 15:08:34 +00:00
Collaborator

Недостающая середина платёжного контура. Тяжёлая половина была сделана раньше и лежала без проводки: клиент Т-Банка, подпись токена, разбор нотификаций, чеки 54-ФЗ (app/services/payments/**, 699 строк с тестами), схема 233_payments.sql применена на проде, периметр под вебхук подготовлен (свой лимитер, скрабинг Sentry, пропуск тела из аудита). Не было роутера, статус-машины и доставки товара.

Всё за payments_enabled (на проде False) → при выключенном флаге ручки отвечают 503. Миграция не понадобилась.

Что внутри

POST /payments/checkout — цена берётся из серверного каталога по product_code, а не из тела запроса. Свой order_id пишется ДО похода в банк. Идемпотентность: живой платёж по (estimate_id, product_code) переиспользуется вместе с уже полученным PaymentURL — второго Init и второго холда не создаётся. Ошибка банка оставляет запись с error_code/error_message и отдаёт 502; слепой повтор Init запрещён контрактом самого init_payment.

POST /payments/notify — вебхук. Сырой INSERT в payment_notifications идёт до разбора и пишется даже при невалидной подписи (token_valid=false) — иначе расследовать нечего. Защита от дублей — ON CONFLICT DO NOTHING на UNIQUE из миграции, а не «проверить-потом-вставить» (это гонка). Сумма сверяется с payments.amount_kopecks: это единственная защита от перекладывания символов внутри подписи. Незнакомый статус → 'UNKNOWN', и предварительные статусы не имеют права затереть уже наступивший CONFIRMED. processed_at ставится ПОСЛЕ выдачи, ответ "OK" — тоже после. Отказ (403/400) только там, где принять означало бы соврать про деньги.

GET /trade-in/r/{token} — доставка. Право доступа — сам токен, RBAC не участвует. 404 одинаковый на «нет», «протух» и «удалено».

GET /payments/status/{order_id} — экран после оплаты; report_url = null, пока выдачи нет, вместо правдоподобной ссылки в 404.

Одно место, где легко было сломать инвариант

Токен доставки положен в payment_entitlements.subject, а ref_id остался estimate_id. На (payment_id, kind, ref_id) держится UNIQUE «выдали ровно один раз» — случайный токен в ref_id сделал бы каждую строку уникальной и молча отменил эту защиту. Выдача продлевает trade_in_estimates.retain_until через GREATEST, иначе purge удалил бы строку через сутки и оплаченная ссылка указывала бы в пустоту.

Чек не выписывается, и это осознанно

TBANK_TAXATION пуст, TBANK_RECEIPT_ENABLED=false. Вместо того чтобы угадать систему налогообложения, код отдаёт жёсткий отказ «receipt is not configured»: подставленный наугад режим означает неверный фискальный документ. Ставка НДС в позиции сейчас none и требует подтверждения бухгалтером вместе с Taxation.

Приёмка на проде (флаг выключен)

ssh poincare "docker exec tradein-backend curl -s -o /dev/null -w '%{http_code}\n' -XPOST -H 'Content-Type: application/json' -d '{}' localhost:8000/api/v1/trade-in/payments/notify"

Ожидается 503: роут есть, приём выключен. 404 означало бы старый образ, 401 — что путь не попал в _PUBLIC_PATHS и вебхук банка получил бы отказ.

Таблицы payments / payment_notifications / payment_entitlements обязаны остаться пустыми при выключенном флаге.

За владельцем — без этого включать нельзя

  1. Договор эквайринга и боевой терминал: TBANK_TERMINAL_KEY / TBANK_PASSWORD на проде отсутствуют вовсе, app/main.py роняет старт при включении флага без них.
  2. TBANK_NOTIFICATION_URL пуст — без него банк вебхук не пришлёт в принципе.
  3. Система налогообложения, версия ФФД, ставка НДС.
  4. Политика автовозврата не реализована. При двухстадийной схеме (pay_type="T", холд) деньги останутся в холде, пока не появится реконсиляция: Confirm внутри вебхука запрещён бюджетом времени — банк ждёт ответа около 10 секунд, а подтверждение занимает заметно дольше. Это осознанный разрез, а не недосмотр, но автовозврат уже обещан в опубликованной политике возврата — значит он обязан появиться вместе с кнопкой оплаты.
Недостающая середина платёжного контура. Тяжёлая половина была сделана раньше и лежала без проводки: клиент Т-Банка, подпись токена, разбор нотификаций, чеки 54-ФЗ (`app/services/payments/**`, 699 строк с тестами), схема `233_payments.sql` применена на проде, периметр под вебхук подготовлен (свой лимитер, скрабинг Sentry, пропуск тела из аудита). Не было роутера, статус-машины и доставки товара. Всё за `payments_enabled` (на проде `False`) → при выключенном флаге ручки отвечают 503. Миграция не понадобилась. ## Что внутри **`POST /payments/checkout`** — цена берётся из серверного каталога по `product_code`, а не из тела запроса. Свой `order_id` пишется ДО похода в банк. Идемпотентность: живой платёж по `(estimate_id, product_code)` переиспользуется вместе с уже полученным `PaymentURL` — второго Init и второго холда не создаётся. Ошибка банка оставляет запись с `error_code`/`error_message` и отдаёт 502; слепой повтор Init запрещён контрактом самого `init_payment`. **`POST /payments/notify`** — вебхук. Сырой INSERT в `payment_notifications` идёт **до** разбора и пишется даже при невалидной подписи (`token_valid=false`) — иначе расследовать нечего. Защита от дублей — `ON CONFLICT DO NOTHING` на UNIQUE из миграции, а не «проверить-потом-вставить» (это гонка). Сумма сверяется с `payments.amount_kopecks`: это единственная защита от перекладывания символов внутри подписи. Незнакомый статус → `'UNKNOWN'`, и предварительные статусы не имеют права затереть уже наступивший `CONFIRMED`. `processed_at` ставится ПОСЛЕ выдачи, ответ `"OK"` — тоже после. Отказ (403/400) только там, где принять означало бы соврать про деньги. **`GET /trade-in/r/{token}`** — доставка. Право доступа — сам токен, RBAC не участвует. 404 одинаковый на «нет», «протух» и «удалено». **`GET /payments/status/{order_id}`** — экран после оплаты; `report_url = null`, пока выдачи нет, вместо правдоподобной ссылки в 404. ## Одно место, где легко было сломать инвариант Токен доставки положен в `payment_entitlements.subject`, а `ref_id` остался `estimate_id`. На `(payment_id, kind, ref_id)` держится UNIQUE «выдали ровно один раз» — случайный токен в `ref_id` сделал бы каждую строку уникальной и молча отменил эту защиту. Выдача продлевает `trade_in_estimates.retain_until` через `GREATEST`, иначе purge удалил бы строку через сутки и оплаченная ссылка указывала бы в пустоту. ## Чек не выписывается, и это осознанно `TBANK_TAXATION` пуст, `TBANK_RECEIPT_ENABLED=false`. Вместо того чтобы угадать систему налогообложения, код отдаёт жёсткий отказ «receipt is not configured»: подставленный наугад режим означает неверный фискальный документ. Ставка НДС в позиции сейчас `none` и требует подтверждения бухгалтером вместе с `Taxation`. ## Приёмка на проде (флаг выключен) ```bash ssh poincare "docker exec tradein-backend curl -s -o /dev/null -w '%{http_code}\n' -XPOST -H 'Content-Type: application/json' -d '{}' localhost:8000/api/v1/trade-in/payments/notify" ``` Ожидается **503**: роут есть, приём выключен. 404 означало бы старый образ, **401** — что путь не попал в `_PUBLIC_PATHS` и вебхук банка получил бы отказ. Таблицы `payments` / `payment_notifications` / `payment_entitlements` обязаны остаться пустыми при выключенном флаге. ## За владельцем — без этого включать нельзя 1. Договор эквайринга и боевой терминал: `TBANK_TERMINAL_KEY` / `TBANK_PASSWORD` на проде отсутствуют вовсе, `app/main.py` роняет старт при включении флага без них. 2. `TBANK_NOTIFICATION_URL` пуст — без него банк вебхук не пришлёт в принципе. 3. Система налогообложения, версия ФФД, ставка НДС. 4. **Политика автовозврата не реализована.** При двухстадийной схеме (`pay_type="T"`, холд) деньги останутся в холде, пока не появится реконсиляция: `Confirm` внутри вебхука запрещён бюджетом времени — банк ждёт ответа около 10 секунд, а подтверждение занимает заметно дольше. Это осознанный разрез, а не недосмотр, но автовозврат уже обещан в опубликованной политике возврата — значит он обязан появиться вместе с кнопкой оплаты.
bot-backend added 1 commit 2026-08-29 13:59:40 +00:00
feat(payments): роутер checkout/notify, статус-машина и выдача по capability-ссылке
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 13s
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 5m4s
42a1247f2b
Не хватало ровно проводки: сервисный слой Т-Банка (PR-C) и схема (PR-B, 233)
уже были, HTTP-ручек и статус-машины — нет, как и доставки купленного.

Всё за kill-switch PAYMENTS_ENABLED (дефолт false): при выключенном контуре
каждая ручка отвечает 503 и не трогает ни банк, ни платёжные таблицы, поэтому
merge на проде не меняет поведения.

Идемпотентность целиком отдана БД (UNIQUE миграции 233 + ON CONFLICT DO
NOTHING), а не паре «проверить-потом-вставить»: между проверкой и вставкой
проходит параллельный ретрай банка, и товар выдаётся дважды. Признаком
«выдача состоялась» служит payment_notifications.processed_at, а не сам факт
строки — иначе падение процесса между записью нотификации и выдачей оставило
бы клиента без отчёта при списанных деньгах.

Доставка — capability-ссылка /api/v1/trade-in/r/<token>: токен лежит в
payment_entitlements.subject (ref_id остаётся estimate_id, на нём держится
UNIQUE «выдали один раз»), режется из GlitchTip-событий и открыт в rbac
отдельным узким префиксом. Тело GET /estimate/{id} вынесено в load_estimate,
чтобы у второго права доступа был тот же загрузчик, а не третья копия
гейта читаемости.
Light1YT added 1 commit 2026-08-29 14:11:58 +00:00
fix(payments): один живой платёж на оценку — гарантия БД, а не порядка выполнения
Some checks failed
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Failing after 14s
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 5m27s
cd0522ceae
Идемпотентность checkout держалась на «SELECT, потом INSERT» — ровно на том,
что шапка модуля называет дефектом. Двойной клик по кнопке оплаты давал два
параллельных запроса, два INSERT, два Init и два холда на карте покупателя.

- миграция 277: частичный UNIQUE (estimate_id, product_code) по живым статусам
  + ON CONFLICT DO NOTHING в INSERT. Проигравший гонку не идёт в банк: отдаёт
  ссылку соперника, если та уже готова, иначе 409;
- граница по времени для брошенных попыток: NEW/FORM_SHOWED старше 30 минут
  переводятся в DEADLINE_EXPIRED. Без неё зависший платёж (нотификации по нему
  может не прийти вовсе) навсегда отдавал покупателю одну и ту же протухшую
  PaymentURL. Окно НЕ распространяется на AUTHORIZED и прочие карточные
  статусы — там деньги уже в игре, разгребать их — работа реконсиляции;
- IDOR: checkout читал оценку без _assert_estimate_access. По чужому
  estimate_id возвращался order_id чужого живого платежа, а order_id — право
  доступа для /payments/status/<order_id>, отдающего capability-ссылку на
  отчёт. Проверка ставится только для оценок с владельцем: у анонимной покупки
  идентичности нет, правом там работает сам estimate_id.

Тесты двусторонние, фальсификация прогнана: снятие ON CONFLICT / границы по
времени / IDOR-гварда красит ровно один тест каждый раз, два из трёх — по
значению ответа.
Light1YT added 1 commit 2026-08-29 14:15:30 +00:00
fix(tradein): миграция платежей 277 → 279 (столкновение номеров) + lock_timeout
All checks were successful
CI Trade-In / changes (pull_request) Successful in 12s
CI / changes (pull_request) Successful in 19s
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 5m21s
93da29ce81
Два параллельных агента взяли ОДИН номер: 277_landing_showcase_runs.sql в ветке
витрины и 277_payments_live_checkout_uidx.sql здесь. Обе ветки по отдельности
зелёные, но на main второй файл встал бы конфликтом — ровно ловушка из шапки
tests/test_migration_numbering.py: номер сверяется с origin/main, а не с чужими
открытыми ветками.

Заняты сейчас: 275 (метрики), 276+277 (витрина), 278 (публичный токен) → этот 279.
Ссылки на номер обновлены в payments.py и test_payments_router.py, включая путь,
по которому тест читает предикат частичного UNIQUE.

Плюс CREATE UNIQUE INDEX на существующей таблице payments обёрнут в
SET LOCAL lock_timeout = '5s' — гейт #2752.
Light1YT force-pushed feat/b2c-payments-router from 93da29ce81 to a48070dd89 2026-08-29 14:38:43 +00:00 Compare
bot-backend merged commit b241e0145a into main 2026-08-29 15:08:34 +00:00
bot-backend deleted branch feat/b2c-payments-router 2026-08-29 15:08:34 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference: lekss361/gendesign#3231
No description provided.