feat(tradein/payments): оплаченный отчёт хранится год — retain_until и предохранители в задаче удаления #2754

Merged
lekss361 merged 5 commits from feat/tradein-paid-retention into main 2026-08-07 13:08:46 +00:00
Owner

PR-D1 платёжного контура. Платёжного кода здесь нет ни строчки — это разрядка мины, которая заряжена уже сейчас.

Спецификация: mera-pr-d-spec.md §1 в корне репо.

Зачем

Владелец продаёт отчёт физлицу за 150 ₽: у клиента файл бессрочно, мы храним год.

Сегодня оценка живёт 24 часа, а задача удаления сносит строки WHERE expires_at < NOW() AND created_by IS NULL — то есть ровно популяцию платящих физлиц. Задача засеяна выключенной, но чек-лист включения лежит в докстринге, и кто-то его выполнит. Первый же прогон после запуска продаж удалил бы оплаченное безвозвратно: PDF нигде не хранится, он рендерится на лету.

Почему не поднять срок жизни оценки

Рассматривалось и отклонено по трём независимым причинам, каждой достаточно:

  • настройка одна и глобальная — поднять её значит дать год всем записям, включая адреса физлиц, которые ничего не купили, а это прямое нарушение минимизации по 152-ФЗ;
  • expires_atотображаемое значение «ДЕЙСТВИТЕЛЕН ДО» в PDF и в интерфейсе; подняв его до года, мы напечатали бы на клиентском документе, что оценка квартиры актуальна год;
  • фронт вычисляет дату расчёта как сдвиг от expires_at на −24 часа — сдвиг сломал бы и её.

Что сделано

Отдельная колонка retain_until (миграция 234, без бэкфилла). Два независимых срока:

поле смысл значение видно клиенту
expires_at актуальность цифры сутки «ДЕЙСТВИТЕЛЕН ДО» в PDF
retain_until срок жизни доступа год при оплате, иначе пусто «ссылка доступна до …»

NULL даёт false в сравнении, поэтому для всех 1058 существующих строк и всего B2B поведение не меняется вообще.

Единый гейт чтения. Их было два, и они уже разошлись по коду ответа: фильтр в SQL давал 404, проверка в Python — 410. Теперь одно определение «оценка читаема» на оба места. Третий потребитель (ссылка-пропуск из будущего PR) разошёлся бы неизбежно.

Текст ответа "estimate expired (24h TTL)" заменён на "estimate expired" — при годовом хранении упоминание суток становится ложью в ответе API.

Задача удаления — два независимых предохранителя плюс предполётная проверка:

  • retain_until IS NULL — именно так, не < NOW(). Оплаченное не удаляется в принципе; ослабление это отдельный PR не раньше чем через год после первой продажи.
  • NOT EXISTS по платежам — независимая страховка на случай, если выдача забыла проставить срок из-за бага или гонки. Строка, которой касались деньги, переживёт задачу в любом случае.
  • Предполётная проверка считает кандидатов по базовому предикату (шире итогового) и при ненулевом результате не начинает прогон, а падает с сообщением. Шире — намеренно: ловить надо именно случай «срок не проставлен, а деньги были», а не штатно защищённые строки.

Удаление лидов не тронуто — там своя механика и настоящий 180-дневный срок.

Число «12 месяцев» больше нигде не хардкодится — одна настройка через переменную окружения, из неё выводятся и текст, и SQL продления.

Страница политики больше не утверждает, что механизма удаления нет — после смерженного PR #2547 это неправда.

Проверка

Миграция прогнана на прод-БД внутри BEGIN … ROLLBACK, тело дважды — идентично.

Неизменность прода проверена мной независимо от отчёта автора: колонки нет, индекса нет, записи в реестре миграций нет, данные целы (1058 строк, из них 129 анонимных).

pytest — 3966 passed, 14 skipped. tsc и линтеры чисто.

Критерии приёмки — тестами

  • на всех существующих строках поведение чтения, PDF и удаления совпадает с текущим (регрессия B2B);
  • строка с истёкшим expires_at, но живым retain_until читается и отдаёт PDF;
  • строка с живым retain_until не выбирается предикатом удаления; строка с любой записью о платеже не выбирается даже при пустом retain_until;
  • предполётная проверка падает при наличии оплаченного кандидата, ноль удалений;
  • «ДЕЙСТВИТЕЛЕН ДО» в PDF по-прежнему сутки.

Замечание по процессу

Автор попутно наткнулся на ловушку: форматтер фронтенда настроен только на frontend/, но не на tradein-mvp/frontend/. Прогон по привычке раздул дифф широким переформатированием несвязанных строк — откатил и применил правки точечно. Стоит иметь в виду при следующих правках фронта МЕРЫ.

PR-D1 платёжного контура. **Платёжного кода здесь нет ни строчки** — это разрядка мины, которая заряжена уже сейчас. Спецификация: `mera-pr-d-spec.md` §1 в корне репо. ## Зачем Владелец продаёт отчёт физлицу за 150 ₽: у клиента файл бессрочно, мы храним год. Сегодня оценка живёт 24 часа, а задача удаления сносит строки `WHERE expires_at < NOW() AND created_by IS NULL` — то есть **ровно популяцию платящих физлиц**. Задача засеяна выключенной, но чек-лист включения лежит в докстринге, и кто-то его выполнит. Первый же прогон после запуска продаж удалил бы оплаченное безвозвратно: PDF нигде не хранится, он рендерится на лету. ## Почему не поднять срок жизни оценки Рассматривалось и отклонено по трём независимым причинам, каждой достаточно: - настройка одна и глобальная — поднять её значит дать год **всем** записям, включая адреса физлиц, которые ничего не купили, а это прямое нарушение минимизации по 152-ФЗ; - `expires_at` — **отображаемое** значение «ДЕЙСТВИТЕЛЕН ДО» в PDF и в интерфейсе; подняв его до года, мы напечатали бы на клиентском документе, что оценка квартиры актуальна год; - фронт вычисляет дату расчёта как сдвиг от `expires_at` на −24 часа — сдвиг сломал бы и её. ## Что сделано **Отдельная колонка `retain_until`** (миграция 234, без бэкфилла). Два независимых срока: | поле | смысл | значение | видно клиенту | |---|---|---|---| | `expires_at` | актуальность цифры | сутки | «ДЕЙСТВИТЕЛЕН ДО» в PDF | | `retain_until` | срок жизни доступа | год при оплате, иначе пусто | «ссылка доступна до …» | `NULL` даёт `false` в сравнении, поэтому для всех 1058 существующих строк и всего B2B поведение не меняется вообще. **Единый гейт чтения.** Их было два, и они уже разошлись по коду ответа: фильтр в SQL давал 404, проверка в Python — 410. Теперь одно определение «оценка читаема» на оба места. Третий потребитель (ссылка-пропуск из будущего PR) разошёлся бы неизбежно. Текст ответа `"estimate expired (24h TTL)"` заменён на `"estimate expired"` — при годовом хранении упоминание суток становится ложью в ответе API. **Задача удаления — два независимых предохранителя плюс предполётная проверка:** - `retain_until IS NULL` — именно так, **не** `< NOW()`. Оплаченное не удаляется в принципе; ослабление это отдельный PR не раньше чем через год после первой продажи. - `NOT EXISTS` по платежам — независимая страховка на случай, если выдача забыла проставить срок из-за бага или гонки. Строка, которой касались деньги, переживёт задачу в любом случае. - Предполётная проверка считает кандидатов по **базовому предикату** (шире итогового) и при ненулевом результате не начинает прогон, а падает с сообщением. Шире — намеренно: ловить надо именно случай «срок не проставлен, а деньги были», а не штатно защищённые строки. Удаление лидов не тронуто — там своя механика и настоящий 180-дневный срок. **Число «12 месяцев» больше нигде не хардкодится** — одна настройка через переменную окружения, из неё выводятся и текст, и SQL продления. **Страница политики** больше не утверждает, что механизма удаления нет — после смерженного PR #2547 это неправда. ## Проверка Миграция прогнана на прод-БД внутри `BEGIN … ROLLBACK`, тело дважды — идентично. **Неизменность прода проверена мной независимо от отчёта автора:** колонки нет, индекса нет, записи в реестре миграций нет, данные целы (1058 строк, из них 129 анонимных). `pytest` — 3966 passed, 14 skipped. `tsc` и линтеры чисто. ## Критерии приёмки — тестами - на всех существующих строках поведение чтения, PDF и удаления совпадает с текущим (регрессия B2B); - строка с истёкшим `expires_at`, но живым `retain_until` читается и отдаёт PDF; - строка с живым `retain_until` не выбирается предикатом удаления; строка с любой записью о платеже не выбирается даже при пустом `retain_until`; - предполётная проверка падает при наличии оплаченного кандидата, ноль удалений; - «ДЕЙСТВИТЕЛЕН ДО» в PDF по-прежнему сутки. ## Замечание по процессу Автор попутно наткнулся на ловушку: форматтер фронтенда настроен только на `frontend/`, но не на `tradein-mvp/frontend/`. Прогон по привычке раздул дифф широким переформатированием несвязанных строк — откатил и применил правки точечно. Стоит иметь в виду при следующих правках фронта МЕРЫ.
lekss361 added 1 commit 2026-08-06 18:56:34 +00:00
feat(tradein/payments): оплаченный отчёт хранится год — retain_until и предохранители в задаче удаления
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 / 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 / frontend-checks (pull_request) Successful in 1m4s
CI Trade-In / backend-tests (pull_request) Successful in 3m51s
5ff06d25b4
Мина: purge_expired_trade_in_data (сейчас enabled=false) удаляет строки
WHERE expires_at < NOW() AND created_by IS NULL — это ровно популяция
будущих платящих физлиц (владелец продаёт отчёт за 150 руб., отчёт должен
жить год на нашей стороне, а не 24ч). Первый прогон после запуска продаж
безвозвратно снёс бы оплаченное.

Делается ДО платёжного кода, которого в этом PR нет:
- migration 234: колонка trade_in_estimates.retain_until (NULL = неоплачено,
  бэкенд-бита-в-бит не меняется) + частичный индекс под purge-предикат.
- config.py: trade_in_paid_retention_days=365 (ENV) — единственный источник
  "12 месяцев" для будущей оферты/экрана/SQL продления.
- Единый гейт чтения ESTIMATE_READABLE_SQL + estimate_readable() — раньше
  SQL-фильтр (404) и Python-проверка (410) в trade_in.py уже разошлись по
  тексту ответа; текст "estimate expired (24h TTL)" убран (стал бы ложью при
  годовом хранении).
- purge_expired_trade_in_data: retain_until IS NULL (не < NOW() — оплаченное
  не удаляем в принципе) + NOT EXISTS(payments) как независимая страховка +
  pre-flight, который считает оплаченных кандидатов и падает в mark_failed
  ДО первого батча при ненулевом результате.
- PDF: "Ссылка доступна до …" только при retain_until IS NOT NULL;
  "ДЕЙСТВИТЕЛЕН ДО" (expires_at, актуальность расчёта) не тронут.
- Фронт: retain_until прокинут в mapper (validUntil остаётся на expires_at).
- privacy-страница: убрано устаревшее "механизма удаления нет" (неправда
  после #2547), добавлен срок 12 месяцев для оплаченных отчётов.

Ни строчки платёжного кода. expires_at, trade_in_estimate_retention_hours,
_DELETE_EXPIRED_LEADS_SQL не тронуты.
bot-backend added 1 commit 2026-08-06 19:49:41 +00:00
fix(tradein/payments): pre-flight должен ловить аномалию, не штатное состояние (review PR #2754)
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 / 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 / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m49s
48664dfe0e
Deep-review MEDIUM: предполётная проверка purge_expired_trade_in_data считала
по базовому предикату без retain_until — здоровая оплаченная строка (retain_until
проставлен, платёж есть) через сутки после продажи тоже попадала под счётчик,
и джоба аварийно останавливалась на первой же честной продаже навсегда
(вместе с ней — и 180-дневное удаление лидов, вызываемое из той же функции
после этой проверки).

- _PREFLIGHT_PAID_CANDIDATES_SQL: добавлен терм `retain_until IS NULL` —
  теперь считает только реальную аномалию (retain_until не проставлен, а
  платёж есть), а не штатное состояние. Докстринги функции/модуля поправлены
  под фактическое поведение.
- Тест на неверный инвариант (`"retain_until" not in sql`) заменён на
  позитивный (`"retain_until IS NULL" in sql`) + добавлены live-DB тесты на
  оба случая из ревью (здоровая оплаченная строка не поднимает тревогу,
  джоба не блокируется).
- privacy/page.tsx: константа "12 месяцев" вынесена в content.ts
  (PAID_REPORT_RETENTION_MONTHS) вместо литерала + расходящегося комментария;
  добавлен сверяющий тест (test_paid_retention_text_consistency.py) по
  образцу _CONSENT_TEXT_SNAPSHOT. Смягчена формулировка про автоматическое
  удаление — задача на проде выключена и ни разу не запускалась, текст
  теперь описывает установленный порядок, а не наблюдаемый факт.
- Все 10 висячих ссылок на untracked `mera-pr-d-spec.md` (7 файлов) заменены
  на краткое изложение сути в комментарии + ссылку на PR #2754.
Collaborator

Заметил при разборе конфликтов, сообщаю до того, как это упрётся в гейт.

Номер миграции 234 занят дважды

где файл
main (смержено вчера, #2765) 234_scrape_runs_ban_kind_unknown.sql
эта ветка 234_trade_in_estimates_retain_until.sql

На проде применён первый — SELECT max(filename) FROM _schema_migrations даёт 234_scrape_runs_ban_kind_unknown.sql. Ветка ответвилась 06.08 от d3d74642, до того как 234 занялся.

Прод отслеживает миграции по голому имени файла, поэтому одинаковый номер с разными именами применится дважды и в разном порядке на разных стендах. Ровно от этого стоит гейт test_migrations_manifest.py; он это поймает, но уже после переименования будет поздно откатывать.

Единственный конфликт слияния у PR — тоже про это: _manifest_applied.txt, обе стороны дописали свои имена. Механически: взять оба блока и отсортировать (гейт требует сортировки и отсутствия дублей, и подскажет, если что-то не так).

Какой номер брать

Свободен 240. Диапазон 235–239 сейчас в работе у моих задач — часть, вероятно, останется неиспользованной, но пока их лучше не занимать:

задача
235 #2672 поштучный процент торга
236 уборка мёртвого кода
237 #2669 views_total
238 #2699 высота потолков
239 #2771 координаты домов

Если возьмёте 240, столкновения не будет ни с чем из перечисленного.

Второй PR в конфликте — #2546

Не связан с этим, но раз уж смотрю: feat/mera-b2c-antiabuse ответвился 26.07, с тех пор в main 241 коммит. Конфликтуют два файла:

  • tradein-mvp/backend/app/api/v1/trade_in.py
  • tradein-mvp/backend/tests/conftest.py

Ещё три сливаются автоматически (core/config.py, services/estimator.py и др.). Чем дольше ветка живёт, тем дороже; если этап 2 из 8 ещё актуален, его дешевле перебазировать сейчас.

Ветки ваши — я в них ничего не трогал и не буду.

Заметил при разборе конфликтов, сообщаю до того, как это упрётся в гейт. ## Номер миграции 234 занят дважды | где | файл | |---|---| | `main` (смержено вчера, #2765) | `234_scrape_runs_ban_kind_unknown.sql` | | эта ветка | `234_trade_in_estimates_retain_until.sql` | На проде применён первый — `SELECT max(filename) FROM _schema_migrations` даёт `234_scrape_runs_ban_kind_unknown.sql`. Ветка ответвилась 06.08 от `d3d74642`, до того как 234 занялся. Прод отслеживает миграции по **голому имени файла**, поэтому одинаковый номер с разными именами применится дважды и в разном порядке на разных стендах. Ровно от этого стоит гейт `test_migrations_manifest.py`; он это поймает, но уже после переименования будет поздно откатывать. **Единственный конфликт слияния у PR — тоже про это:** `_manifest_applied.txt`, обе стороны дописали свои имена. Механически: взять оба блока и отсортировать (гейт требует сортировки и отсутствия дублей, и подскажет, если что-то не так). ## Какой номер брать **Свободен 240.** Диапазон **235–239 сейчас в работе** у моих задач — часть, вероятно, останется неиспользованной, но пока их лучше не занимать: | № | задача | |---|---| | 235 | #2672 поштучный процент торга | | 236 | уборка мёртвого кода | | 237 | #2669 `views_total` | | 238 | #2699 высота потолков | | 239 | #2771 координаты домов | Если возьмёте 240, столкновения не будет ни с чем из перечисленного. ## Второй PR в конфликте — #2546 Не связан с этим, но раз уж смотрю: `feat/mera-b2c-antiabuse` ответвился **26.07**, с тех пор в `main` 241 коммит. Конфликтуют два файла: * `tradein-mvp/backend/app/api/v1/trade_in.py` * `tradein-mvp/backend/tests/conftest.py` Ещё три сливаются автоматически (`core/config.py`, `services/estimator.py` и др.). Чем дольше ветка живёт, тем дороже; если этап 2 из 8 ещё актуален, его дешевле перебазировать сейчас. Ветки ваши — я в них ничего не трогал и не буду.
bot-backend added 2 commits 2026-08-07 12:44:09 +00:00
main уехал вперёд за сутки: 234 занял 234_scrape_runs_ban_kind_unknown.sql
(0de22f4b), максимум на main сейчас 239 (235-237 — дыры). max+1=240 безопаснее
дыр; ни один открытый PR номер 235-240 не занимает (сверено по forgejo/main и
всем открытым веткам).

Переименован файл + обновлены все 7 упоминаний "migration 234"
(_manifest_applied.txt, config.py, schemas/trade_in.py,
purge_expired_trade_in_data.py, test_estimate_idor.py, content.ts,
types/trade-in.ts) — правки текстовые, ни один тест не читает миграцию по
имени файла.
Merge remote-tracking branch 'forgejo/main' into feat/tradein-paid-retention
All checks were successful
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 8s
CI / backend-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 / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m3s
CI Trade-In / backend-tests (pull_request) Successful in 3m57s
7431615415
# Conflicts:
#	tradein-mvp/backend/data/sql/_manifest_applied.txt
#	tradein-mvp/frontend/src/components/trade-in/v2/fixtures.ts
bot-backend added 1 commit 2026-08-07 12:59:46 +00:00
fix(tradein/payments): SET LOCAL lock_timeout в миграции 240 (gate threshold — артефакт)
All checks were successful
CI Trade-In / browser-tests (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / changes (pull_request) Successful in 8s
CI / changes (pull_request) Successful in 9s
CI / frontend-tests (pull_request) Has been skipped
CI Trade-In / frontend-checks (pull_request) Successful in 1m1s
CI Trade-In / backend-tests (pull_request) Successful in 4m1s
5ce95a28a8
check-migration-lock-timeout.py требует lock_timeout только для NN >= 250 в
tradein — порог назначен по номеру аварийной миграции 250, которую затем
сняли с деплоя (#2792). Фактический максимум применённого на main — 239,
то есть весь диапазон 240-249 гейтом не проверяется вообще ("проверено
новых миграций: 0" = не проверено ни одного файла, не "все чисты"). Функция
scan() из самого гейта, прогнанная напрямую без порогового отсечения,
помечает ALTER TABLE в этом файле как блокирующий DDL без lock_timeout.

trade_in_estimates — самая горячая таблица стека (история, история
сотрудников, каждое чтение/PDF оценки); на этой БД уже наблюдались открытые
транзакции на 46 и 22 часа. Ждущая ACCESS EXCLUSIVE-блокировка встаёт в
очередь перед новыми запросами приложения. DDL на 1058 строках мгновенный —
риск не в исполнении, а в ожидании чужой блокировки.

Добавлено SET LOCAL lock_timeout = '5s' сразу после BEGIN + объяснение в
шапке файла, почему оно здесь при том что гейт формально не требует —
чтобы не убрали как "лишнее". Порог гейта не трогаю — отдельный issue.
lekss361 merged commit ef82172bd1 into main 2026-08-07 13:08:46 +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#2754
No description provided.