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

Таймаут между вашим сервисом и платёжным провайдером может превратить один клик по кнопке оплаты в два списания, а возврат — в запись без движения денег. Надёжный биллинг строится не вокруг поля status, а вокруг неизменяемого журнала операций, идемпотентных команд и явного разделения платежа, списания и возврата.
Сначала разделите сущности
Главная архитектурная ошибка — хранить всё в одной строке заказа: сумму, статус оплаты, дату возврата и идентификатор операции провайдера. Такая модель быстро ломается, когда платёж состоит из нескольких попыток, возврат выполняется частями, а уведомление приходит повторно или раньше ответа на API-запрос.
Минимальная модель должна различать четыре сущности:
- Заказ — бизнес-объект: что покупатель оформил и на какую сумму.
- Платёж — попытка получить деньги по заказу. У одного заказа может быть несколько попыток.
- Операция движения денег — подтверждённое списание, возврат, корректировка или комиссия.
- Событие провайдера — входящее уведомление, которое нужно принять, проверить и обработать идемпотентно.
Заказ не должен быть единственным источником истины о деньгах. Его статус можно пересчитать из операций или обновить проекцией, но сами операции после подтверждения не следует переписывать.
Сноска. Идемпотентность означает, что повтор одной и той же команды даёт тот же денежный результат, что и один вызов: второе списание или второй возврат не создаются.
Деньги храните как журнал, а не как баланс
Баланс вроде paid_amount удобен для интерфейса, но опасен как единственная база расчёта. При частичном возврате, спорном платеже или ручной корректировке непонятно, кто и когда изменил число. Без истории невозможно восстановить состояние после сбоя и объяснить клиенту расхождение.
Надёжнее хранить отдельные записи операций. Для каждой записи нужны как минимум:
- внутренний идентификатор и идентификатор заказа;
- тип операции: списание, возврат или корректировка;
- сумма в минимальных единицах валюты, например в копейках, если это соответствует валюте;
- код валюты;
- состояние операции;
- идентификатор операции у провайдера;
- ключ идемпотентности и время создания;
- ссылка на исходную операцию для возврата;
- время подтверждения и диагностические данные без секретов платёжного метода.
Сумму нельзя хранить в бинарном числе с плавающей точкой: арифметические ошибки проявляются на границах округления. При этом правило минимальных единиц зависит от валюты, поэтому нельзя без проверки считать, что для любой валюты достаточно умножения на 100.
Списание и возврат — это не переключение статуса заказа, а две разные попытки изменить денежное состояние, каждая со своей историей, повтором и результатом.
Спроектируйте состояния отдельно для каждой операции
Состояния должны описывать не желание пользователя, а подтверждённый этап обработки. Для списания обычно нужны состояния вроде created, processing, succeeded, failed, canceled. Точный набор зависит от провайдера: некоторые операции могут быть подтверждены позже, а отдельные способы оплаты имеют промежуточное ожидание.
Возврат нельзя сводить к полю refunded = true. У него должна быть собственная запись и собственный жизненный цикл: создан, принят провайдером, обрабатывается, завершён или отклонён. Частичный возврат создаёт новую операцию на конкретную сумму, связанную с исходным списанием.
| Сценарий | Что хранить | Что нельзя делать |
|---|---|---|
| Списание подтверждено | Операцию с суммой, валютой и внешним идентификатором | Считать успешным только ответ своего HTTP-запроса |
| Ответ провайдера потерян | Операцию в состоянии обработки и ключ повтора | Создавать новое списание без проверки первой попытки |
| Полный возврат | Возврат, связанный с исходным списанием | Удалять исходное списание или обнулять его сумму |
| Частичный возврат | Отдельную сумму возврата и накопленный итог | Разрешать возвраты сверх доступного остатка |
| Повторное уведомление | Уникальный идентификатор входящего события | Повторно запускать бизнес-обработку |
Идемпотентность: защищайте команды и события
Повтор команды на списание
Клиент нажал кнопку, сервис отправил запрос, провайдер списал деньги, а соединение оборвалось до ответа. Повторная команда должна использовать тот же ключ, а не генерировать новый. В вашей базе ключ связывается с конкретным заказом, суммой и валютой.
Если тот же ключ пришёл с другой суммой или другим заказом, это не «повтор»: сервис должен отклонить конфликт и записать его для расследования. Уникальность нужно обеспечивать ограничением базы данных, а не только проверкой в прикладном коде. Иначе два параллельных запроса оба успеют пройти условие «такой записи ещё нет».
Повтор входящего события
Webhook может прийти несколько раз, в другом порядке или после ручного запроса статуса. Сначала сохраните событие с уникальным внешним идентификатором в транзакции базы данных, затем обработайте его. Если такой идентификатор уже есть, повтор должен завершаться без нового движения денег.
Проверяйте подпись уведомления и не принимайте в качестве доказательства только идентификатор из тела запроса. Секрет проверки храните вне репозитория и не записывайте в логи.
Граница транзакции: база не заменяет провайдера
Транзакция PostgreSQL 16 может атомарно сохранить вашу запись, но не способна откатить уже выполненное списание во внешней системе. Поэтому не пытайтесь сделать вид, будто вызов платёжного API и запись в базе — одна транзакция.
Рабочая последовательность выглядит так:
- Создайте локальную операцию со статусом обработки и уникальным ключом.
- Отправьте запрос провайдеру с тем же ключом.
- Сохраните полученный внешний идентификатор и результат, если ответ пришёл.
- Если ответа нет, оставьте операцию в неопределённом состоянии и запустите сверку, а не повторное списание с новым ключом.
- Обновляйте заказ только после подтверждённого результата операции.
Для большого потока запросов удобно использовать очередь и отдельного обработчика. Но очередь не делает операцию идемпотентной сама по себе: сообщения тоже могут доставляться повторно.
Возврат — отдельная команда с проверяемым остатком
Перед возвратом вычислите доступную сумму: подтверждённое списание минус уже завершённые возвраты. Проверка должна выполняться внутри транзакции с блокировкой или другим согласованным механизмом, иначе два параллельных запроса на возврат могут вместе превысить доступный остаток.
Возврат отправляется только после создания локальной операции. Если провайдер ответил таймаутом, нельзя сразу считать возврат неудачным: деньги могли уйти. Нужна сверка по внешнему идентификатору, ключу идемпотентности или периодическому запросу статуса — в зависимости от возможностей конкретного провайдера.
Не смешивайте возврат денег с отменой заказа. Заказ может быть отменён до списания, а деньги — возвращены после отгрузки. Это разные бизнес-события и разные правила доступа.
Что ломается первым
- Двойное списание после таймаута. Причина — новый ключ при повторе. Исправление — сохранять ключ до внешнего вызова и выполнять сверку неопределённых операций.
- Возврат больше суммы платежа. Причина — проверка остатка вне транзакции. Исправление — атомарно резервировать доступную сумму.
- Заказ показывает «оплачен», хотя платёж отклонён. Причина — статус обновляется по факту отправки запроса. Исправление — менять его только по подтверждённому состоянию.
- Webhook повторно выдаёт товар. Причина — событие не имеет уникального ограничения и обработчик не проверяет его повтор. Исправление — журнал входящих событий и идемпотентная выдача результата.
- Невозможно найти расхождение. Причина — перезапись суммы и статуса вместо журнала. Исправление — неизменяемые операции и корреляционные идентификаторы в логах.
Как проверять систему под нагрузкой
Не подставляйте вымышленные цифры производительности. Измерьте отдельно время ответа API, задержку webhook, размер очереди, число операций в неопределённом состоянии, долю повторных событий и время сверки. Тестируйте хотя бы такие случаи: два параллельных списания, два возврата на остаток, повтор webhook, задержка ответа после успешного списания и восстановление обработчика после падения.
Для каждого теста заранее определите инварианты: подтверждённая сумма не меняется, сумма завершённых возвратов не превышает списание, один ключ не создаёт две внешние операции, а заказ не получает товар дважды. Эти проверки важнее среднего времени ответа.
Что делать дальше
Зафиксируйте контракт адаптера платёжного провайдера: создание операции, запрос статуса, возврат, проверка уведомления и сверка. Затем добавьте миграции с уникальными ограничениями, журнал входящих событий и фоновую обработку неопределённых операций. В продакшене первым делом смотрите не на число успешных платежей, а на зависшие операции, расхождения между вашим журналом и провайдером и повторные попытки с конфликтующими параметрами.
Детали статусов, сроков обработки и поведения возврата сверяйте с документацией конкретного провайдера: единая модель выше описывает границы системы, но не заменяет его API-контракт.
Логотип и фирменный стиль: Три концепции разного характера, палитра с обоснованием, проверка на читаемость в малом размере и в один цвет.
Спросить за 60 ₽Источники: Stripe — Refunds, PostgreSQL 16 Documentation — Transaction Isolation
Частые вопросы
Можно ли хранить только итоговый баланс заказа?
Что делать, если запрос на списание завершился таймаутом?
Как понять, что возврат не выполнится дважды?
Подойдёт ли эта схема для другого платёжного провайдера?
Что делать, если webhook пришёл раньше ответа на запрос?
Какие подводные камни появляются в продакшене?
Материал носит информационный характер и подготовлен редакцией «Агентуры». Он не является офертой, рекламой или индивидуальной консультацией. Упомянутые продукты, компании и торговые знаки принадлежат их правообладателям. Перед принятием решений, влекущих юридические или финансовые последствия, обратитесь к профильному специалисту.