Как исправлять застрявшие платежи вручную при наличии реконсайл-джобы
Что проверяют
Понимаете ли вы, что ручная правка платежа это такая же операция с деньгами, как автоматическая, и она не должна обходить машину состояний, журнал и саму реконсайл-джобу. Следом спрашивают, что будет, если джоба и человек тронут один платёж одновременно.
Как отвечать
Каркас из четырёх шагов.
Сначала выясните истину у провайдера. Статус в своей базе это лишь последнее, что вы о платеже знаете, поэтому запросите его у эквайера по внешнему идентификатору или ключу идемпотентности. Ответ «неизвестно» не равен «не прошёл»: пометить такой платёж неуспешным и отменить заказ значит оставить клиента со списанными деньгами без товара.
Затем исключите гонку с джобой:
- отдельный статус: платёж переводится в
manual_review, и джоба такие не выбирает; - блокировка строки: джоба берёт пачку через
SELECT ... FOR UPDATE SKIP LOCKED, ручная команда держит ту же блокировку; - условный переход:
UPDATE payments SET status = $1 WHERE id = $2 AND status = 'pending', и еслиRowsAffected()вернул 0, кто-то успел раньше.
Третье: правка идёт через тот же сервисный слой, что и автоматика. Удобно сделать подкоманду в том же Go-бинаре или внутренний админский эндпоинт, который вызывает машину состояний, пишет проводки в леджер, публикует событие и уведомление. Прямой UPDATE из консоли всё это пропускает. Нужны режим --dry-run, аудит (кто, когда, по какому тикету) и для крупных сумм подтверждение вторым человеком.
Четвёртое: разберите, почему джоба не справилась сама. Обычно это новый код ответа провайдера, который она не умеет маппить, или таймаут без повтора. Добавьте этот случай в джобу, а на возраст самого старого платежа в pending заведите метрику с алертом.
Где спотыкаются
Правят статус прямым SQL. В таблице платежей всё сошлось, но леджер, вебхуки мерчанту и письмо клиенту остались в старом состоянии.
Повторяют списание с новым ключом идемпотентности и получают двойное списание.
Исправляют конкретные платежи и закрывают тикет. Через неделю зависших снова сотня, потому что причину в джобе никто не чинил.