Напоминания об оплате не отправлялись полтора месяца. Мы нашли причину — и переписали весь путь отключения
Эту цепочку у нас называют «дожимом». Делать её понятной и предсказуемой мы решили давно: единый календарь для всех подписок — три попытки списания, по одной в день после конца оплаченного периода, напоминание после первой неудачи и после второй, а на третий день, если оплаты так и нет, — отключение. Без карты на руках логика та же: тот же календарь дней, просто вместо попытки списания — ссылка на оплату.
Пока переписывали правило, решили на всякий случай проверить, как дожим ведёт себя на реальных подписках за последние недели. И тут выяснилось то, чего мы не ожидали: за всё это время дожим не сделал вообще ничего. Ни одного напоминания. Ни одного отключения по этой цепочке. С 14 сентября — то есть почти полтора месяца, 47 дней.
Причина оказалась мелкой до неловкости. Чтобы продвинуть подписку на следующую ступень дожима, код выполнял, грубо говоря, две операции с одним и тем же числом — номером ступени: `SET dunning_stage = :st` (в столбец типа `smallint`) и рядом `CASE WHEN CAST(:st AS int) …`. Для человека это одно и то же число. А для драйвера Postgres (asyncpg) — два разных типа параметра с одним и тем же именем в одном запросе, и это прямая ошибка: `AmbiguousParameterError`. Каждый такой клейм ступени падал ещё на старте, не дойдя до сути, и тихо уходил в собственный лог ошибок — `billing.dunning.fail`, — который никто не разбирал каждый день, потому что обычно там пусто.
Самое неприятное — то, что это не было видно по тестам. У нас есть тестовый дублёр базы, который имитирует Postgres для быстрых прогонов, и на нём всё было зелёным: дублёр не настолько строг к типам столбцов, чтобы поймать рассинхрон `int` и `smallint` для одного параметра. Зелёный тест фактически прикрывал мёртвую функцию: по логике кода дожим существовал, по факту — нет.
Пока искали, нашли рядом вторую проблему, уже не техническую, а в самой политике. Старая логика позволяла отмене случиться двумя разными путями: если у подписки была привязана карта, после нескольких неудачных списаний рекуррентный модуль мог сам тихо перевести её в «отменена» — без отключения доступа в кабинете, без письма клиенту и, что хуже, минуя саму выборку дожима. То есть один путь молчал из-за опечатки в типах, а другой — молчал по собственной задумке. Два независимых молчания, которые с разных сторон давали один и тот же результат: клиент не получал ни напоминания, ни внятного объяснения, что происходит с доступом.
Переписали оба пути в один. Теперь после конца периода статус всегда и только `past_due`, тихой отмены больше нет. Единственная точка, которая отключает кабинет, — модуль дожима на третий день, с понятной меткой причины и письмом клиенту. Дата отключения в этом письме — не расчётная «через три дня после конца периода», а дата настоящего прогона в этот день, потому что между этими двумя датами на практике бывает разница в несколько часов из-за расписания фоновых задач, и письмо с неверной датой хуже, чем отсутствие письма.
Чтобы такое больше не повторилось незаметно, добавили интеграционный тест, который гоняется не на дублёре, а на настоящем Postgres и проверяет весь путь целиком, а не один шаг: три неудачные попытки подряд → статус `past_due` → дожим доходит до третьего дня → кабинет отключается с меткой `dunning`, тариф переключается на бесплатный, клиенту уходит письмо. Этот тест на старом коде красный — ровно там, где раньше тихо падал `AmbiguousParameterError`. На новом коде — зелёный на всех трёх днях календаря, и с картой, и без неё, и отдельно проверено, что оплата между попытками сбрасывает ступень обратно.
Для себя мы забрали из этой истории два правила. Первое: если у фонового процесса есть собственный «тихий» лог ошибок, в который он сам же и складывает свои провалы, — рано или поздно он перестанет работать именно так, что никто не заметит: не громким падением, а бесшумным нулём полезной работы. Нужен не только лог, а отдельная проверка по факту — «дожим хоть раз действительно сработал за сутки», а не «ошибок в логе не прибавилось». Второе: расхождение типов между столбцом таблицы и параметром запроса — это ровно тот класс багов, который тестовый дублёр базы почти никогда не воспроизводит, а строгий драйвер настоящей СУБД ловит всегда. Если у вас есть автосписание или любая другая фоновая логика, завязанная на типы колонок, прогоняйте критичный путь целиком на настоящей базе, а не только на быстром дублёре — зелёные тесты на дублёре в этом месте ничего не гарантируют.