Оплата прошла, но тариф не применился

Симптом

Пользователь успешно оплатил месячную подписку, но получил доступ только на несколько дней вместо полного оплаченного периода. Статус платежа был успешным, а тариф, связанный с платежом, не был применён корректно.

Контекст

Проверка данных показала успешный платёж без связи с пользовательским тарифом. При этом сохранённый в платеже снимок тарифных данных содержал правильный месячный период. Короткий тариф был создан отдельным механизмом бесплатного пробного периода.

Порядок операций был важнее самих значений: платёж создался раньше, чем параллельная операция успела зафиксировать trial-тариф. Поэтому покупка увидела отсутствующий текущий тариф.

Корневая причина

Покупка сначала получала идентификатор активного тарифа. Для нового пользователя он был NULL. После успешного webhook обработчик пытался обновить тариф по этому идентификатору.

Условие вида WHERE user_tariff_id = NULL не обновляет ни одной строки. Ошибка была особенно опасной потому, что слой доступа к базе трактовал результат как обычный no-op: оплаченный тариф не создавался, а обработка не сообщала о проблеме.

Параллельно frontend запускал создание trial. Возникала гонка: покупка могла успеть создать платёж до фиксации trial, хотя код покупки исходил из того, что активный тариф уже существует.

Ошибочное предположение

Мы считали, что у каждого покупателя уже есть текущий тариф, который достаточно обновить. После появления конкурентного создания trial это перестало быть инвариантом, и старый update-only путь стал достижимым в обычном пользовательском сценарии.

Обнаружение

Инцидент нашли по обращению пользователя. За несколько минут его мог бы найти мониторинг количества успешных платежей без связи с пользовательским тарифом.

Кроме того, обновление, затронувшее ноль строк из-за отсутствующего идентификатора, должно логироваться или завершаться ошибкой, а не молча считаться успешным.

Исправление

Платёжный обработчик стал работать по принципу update-or-create: если связи нет или обновление не затронуло строк, он создаёт обычный оплаченный тариф из сохранённых данных платежа. Ошибка создания теперь возвращается явно.

Дополнительно:

Для этого сценария были добавлены unit-тесты платёжного обработчика.

Проверка

Регрессионная проверка должна покрывать как минимум два случая:

  1. webhook успешной оплаты приходит до создания trial - оплаченный тариф всё равно создаётся с правильным периодом;
  2. один и тот же webhook приходит конкурентно несколько раз - применение тарифа выполняется ровно один раз.

Отдельно нужно проверять, что update по отсутствующей связи не завершается тихим успехом и что ошибка применения тарифа не скрывается после смены статуса платежа.

Ограничения исправления

Статус платежа и применение тарифа всё ещё должны изменяться в согласованной транзакции. Иначе после успешной смены статуса может остаться частично применённый результат, если следующий шаг завершится ошибкой. Это должно быть отдельным монитором и следующим шагом усиления платёжного пути.

Общий вывод

Операция «обновить уже существующее состояние» перестаёт быть безопасной, когда появляется конкурентный создатель этого состояния. В критичных денежных путях update-only нужно заменять на upsert или create-if-absent, проверять количество затронутых строк и делать обработку webhook идемпотентной через атомарный переход состояния.