Skip to main content
У клиента, который не может заплатить полную цену прямо сейчас, есть два пути: пополнить кошелёк и купить позже или сохранить заказ и позволить ему купить себя самому, как только баланс его покроет. Оба переиспользуют уже существующий коммерческий конвейер — ни пополнение, ни корзины не вводят второго способа двигать деньги.

Пополнение — это обычный заказ

Пополнение — это заказ с operation = 'topup'. Он получает платёжное намерение, оформление у провайдера, приём вебхуков, сверку и обработку возвратов ровно так же, как покупка подписки. Отличается то, что производит расчёт: средства на кошельке клиента вместо права на услугу.
Пополнение никогда не тратит средства кошелька. Финансировать кошелёк из того же кошелька было бы замкнутым кругом, поэтому wallet_minor всегда равен нулю — и, поскольку кошельку нечего покрывать, пополнение всегда создаётся в состоянии pending с провайдером, которому надо заплатить. Рядом с заказом существует одна строка wallet_topups, хранящая запрошенную сумму, зачисленную сумму и проводочную транзакцию, которая её зачислила. Её собственный CHECK связывает две последние вместе: пополнение зачислено ровно тогда, когда у него есть проводочная транзакция, — никогда одно без другого.

Как оно создаётся

Хранилище выполняет всё создание в сериализуемой транзакции:
  1. Идемпотентность. Существующий заказ под ключом вызывающей стороны с другой операцией, валютой или суммой получает отказ; тот же самый — возвращается.
  2. Обслуживание. Пополнение — это покупка, поэтому оно получает отказ 503 maintenance_active, пока установка находится в окне обслуживания. См. Режим обслуживания.
  3. Допуск по окну. Суммируется то, что клиенту фактически зачислили внутри скользящего окна.
  4. Лимиты. Запрошенная сумма проверяется на соответствие настроенным границам.
  5. Заказ и строка wallet_topups записываются с платёжным окном в один час, если вызывающая сторона не задала своё и не назвала провайдера manual, которому даётся семьдесят два часа, — см. окно оплаты.

Зачисление ровно один раз

Зачисление — это одна проводочная транзакция, ключом которой служит заказ:
ledger_transactions.idempotency_key уникален, и (reference_type, reference_id, type) тоже уникальны, поэтому повторно доставленный вебхук, опрос сверки, пришедший после вебхука, и ручное подтверждение — все сходятся к одной и той же единственной транзакции. Отметка времени wallet_topups.credited_at ставится в той же транзакции, и это третья защита: зачисление начинается с блокировки строки через FOR UPDATE и возвращается, ничего не записав, если эта отметка уже проставлена. Как только зачисление зафиксировано, пишется событие outbox wallet.credited и ставится в очередь уведомление оператору вида topup, дедуплицированное по заказу.

Переплата и недоплата

Заказ на подписку, получивший неверную сумму, остаётся в ожидании, потому что у того, что он покупает, фиксированная цена. У пополнения такого ограничения нет: клиент попросил добавить денег, и деньги пришли. Поэтому Omniflow зачисляет ровно то, что рассчитал провайдер, и помечает заказ оплаченным: Расхождение записывается как платёжное событие и попадает в операторскую тему пополнений, поэтому оно видно без того, чтобы о нём сообщал клиент. См. Классификацию платежей.

Лимиты

Применяются три настраиваемые оператором границы, все в минимальных единицах валюты расчёта:
  • минимум, который никогда не бывает ниже одной минимальной единицы, поэтому пополнение на нулевую сумму создать нельзя;
  • максимум для одного пополнения, который значение 0 убирает целиком;
  • потолок скользящего окна на то, сколько может быть зачислено в течение настроенного периода, который значение 0 отключает.
Окно считает то, что было действительно зачислено, поэтому неудавшаяся, истёкшая или брошенная попытка никогда не расходует допуск клиента. Каждый отказ несёт устойчивую причину — topup_below_minimum, topup_above_maximum, topup_window_exceeded, topup_disabled, topup_invalid_amount, — которая выдаётся как слаг type проблемы RFC 9457 и как ключ текста, отрисовываемый ботом на языке клиента. Предустановленные суммы фильтруются по тем же лимитам до отрисовки, поэтому ни одна из поверхностей никогда не показывает сумму, в которой бэкенд откажет.

Пополнение кошелька в браузере

/account/wallet показывает баланс в каждой валюте, которую держит клиент, — общий, зарезервированный и доступный, — затем кнопки предустановленных сумм, поле свободного ввода и способы оплаты, способные рассчитаться в этой валюте. То, что человек вводит в основной единице, конвертируется в минимальные единицы перед отправкой. Top up отправляет POST /v1/account/wallet/top-up с {amountMinor, currency, provider} и обязательным заголовком Idempotency-Key. Заголовок обязателен, а не опционален, потому что пополнение создаёт в кошельке настоящие деньги, поэтому ключ вызывающей стороны становится ключом заказа и повторённый запрос не может создать их дважды. Ответ — 201 с {orderId, currency, amountMinor, state, payment}, и страница отрисовывает ту передачу управления, которую вернул провайдер: ссылку на размещённое оформление, счёт Telegram, «оператор подтвердит это» для ручного перевода — или вообще ничего. GET /v1/account/wallet возвращает балансы, страницу проводок с курсорной разбивкой и саму политику пополнения — enabled, minimumMinor, maximumMinor, presets, remainingWindowMinor и доступных провайдеров, — поэтому экрану никогда не приходится гадать, что примет сервер.
Если заказ создан, а провайдер затем отказал в платёжном намерении, API всё равно возвращает заказ. Заказ существует и принадлежит клиенту; отбросить его означало бы оставить строку, до которой никто не может добраться. Клиент может снова начать по нему оплату со страницы заказа.

Пополнение кошелька в Telegram

Wallet → Top up предлагает те же предустановленные суммы, плюс custom для ввода суммы обычным сообщением, затем способ оплаты, затем экран статуса заказа. Суммы, введённые в основной единице, разбираются с теми же экспонентами валют, что использует веб-панель. History перечисляет последние десять попыток с состоянием, которого достигла каждая, — собственное состояние заказа или credited, как только записана проводка зачисления. Попытка, всё ещё находящаяся в ожидании, ведёт прямо к своему заказу — на тот же экран восстановления, что использует покупка подписки, включая обновление и объяснение про дублирующий платёж.

Сохранённые корзины

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

Сохранённая цена — никогда не та цена, которая списывается

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

Автоматическая покупка

При включённой автоматической покупке корзина списывается, как только кошелёк её покрывает. Решение вычисляется в фиксированном порядке — автоматическая покупка выключена, затем истёк срок, затем режим обслуживания, затем нехватка баланса, — поэтому причина, которую показывают клиенту, это первая, что действительно применима. Попытку запускают три вещи:
  1. Рассчитанное пополнение — сразу после фиксации его транзакции.
  2. Ежеминутный проход, который повторяет до 200 корзин с автоматической покупкой, принадлежащих клиентам, у которых есть история кошелька в валюте корзины. Именно это делает так, что реферальная награда или коррекция от оператора тоже освобождают корзину.
  3. Нажатие клиентом Buy now.
Проход — это то, что делает функцию честной: «как только пополнение покроет остаток» — это частый случай, но клиент, чей баланс пришёл другим способом, получает тот же исход.
Проход выполняется внутри прохода commerce_maintenance на воркере. В развёртывании без работающего воркера остаются лишь два триггера: рассчитанное пополнение и собственное нажатие клиентом Buy now, а корзины сами по себе никогда не истекают.

Двойное списание невозможно

Заказ создаётся под собственным ключом идемпотентности корзины, который чеканится один раз при сохранении корзины. orders (user_id, idempotency_key) уникален, поэтому каждый путь к покупке — хук расчёта, проход и нажатие клиентом Buy now — разрешается в один и тот же единственный заказ, даже если все три выполняются одновременно. Пометка корзины как купленной сама по себе обусловлена тем, что корзина всё ещё открыта, и это закрывает окно, в котором вторая попытка могла бы увидеть её доступной. Редактирование корзины чеканит новый ключ. Это сделано намеренно: изменённый тариф — это другая покупка, и она не должна разрешаться в заказ, созданный предыдущим содержимым. Частичный уникальный индекс carts (user_id) WHERE status = 'open' сериализует редактирование относительно выполняющейся покупки, а CHECK status = 'purchased' ⟺ order_id IS NOT NULL означает, что корзина никогда не может утверждать, что купила то, чего не может назвать. Дополнения корзины списываются тем же заказом, что и тариф, поэтому корзина — это всегда ровно один заказ и один платёж, сколько бы всего в ней ни было.

Отказы

Автоматическая покупка отклоняется, а не падает с ошибкой, и всегда говорит почему: Только первые две требуют действий от клиента. Каждый сбой записывается в корзину с увеличением счётчика попыток, и корзина остаётся сохранённой во всех случаях, кроме явной очистки.

Использование корзины в Telegram

Сохранённые корзины — функция Telegram; в веб-панели элемента управления сохранением сегодня нет.
  • Save появляется на экране оплаты, когда баланса не хватает, и сохраняет с включённой автоматической покупкой.
  • Cart показывает перепроверенную цену, чего ещё не хватает и сколько времени осталось.
  • Auto-purchase on/off переключает списание без участия клиента, не отбрасывая корзину.
  • Buy now списывает немедленно.
  • Clear отбрасывает её.
Корзина, чей тариф или дополнение исчезли, отрисовывает экран «устарело» с кнопкой Clear, а не цену, которую она не может вычислить.

Корзины магазина никогда не покупают себя сами

Сохранённая покупка цифровых товаров хранится с выключенной автоматической покупкой и пропускается проходом. Цена в магазине — это котировка поставщика, истекающая за минуты, поэтому списание по ней без участия клиента списало бы число, которое клиент последний раз видел несколько дней назад. Полное обоснование — на странице Подарки и цифровые товары. У клиента одна открытая корзина, поэтому сохранение позиции магазина заменяет сохранённый тариф и наоборот.
Рассчитанное пополнение просит открытую корзину клиента купить себя, не проверяя, какого рода эта корзина. Когда это корзина магазина, попытка падает с cart_plan_unavailable и увеличивает счётчик попыток на корзине, которая никогда и не должна была списываться без участия клиента. Ничего не куплено и ничего не списано; то, что вы видите, — это сбой, записанный на корзину, которая ведёт себя правильно.

Что настраивает оператор

Лимиты пополнения и время жизни корзины — это переменные окружения, читаемые при старте процесса. Старт отказывает конфигурации, чей ненулевой максимум ниже минимума, потому что такая комбинация может только отклонять любую сумму. Полный справочник переменных — Конфигурация.
/admin/settings показывает карточку Wallet top-up с теми же полями, и её сохранение пишет строку commerce_settings и событие аудита — но лимиты, которые действительно применяет хранилище, берутся из переменных окружения выше. Ничто не читает commerce_settings обратно в путь заказа, поэтому изменение чисел в панели меняет то, что показывает эта страница, и больше ничего. Настраивайте лимиты пополнения в окружении и перезапускайте затронутые процессы.

Инварианты, стоящие за обеими функциями

  • Пополнение зачисляет ровно ту сумму, которую рассчитал провайдер, ровно один раз, с ключом по своему заказу, и никогда не тратит средства кошелька.
  • Пополнение зачислено ровно тогда, когда оно несёт проводочную транзакцию, — это CHECK в таблице, а не договорённость.
  • Сохранённая корзина переоценивается по текущему каталогу перед любым списанием и списывается под одним ключом идемпотентности, сколько бы путей ни пытались это сделать одновременно.
  • Одна открытая корзина на клиента и одно открытое оформление заказа на клиента.
Они перечислены вместе со всем остальным на странице Финансовые инварианты, а транзакционные из них доказываются против настоящего PostgreSQL в наборе интеграционных тестов: зачисление пополнения ровно один раз при повторах, разрешение переплат и недоплат в книге проводок и списание по корзине ровно один раз при конкурентности. См. Стратегию тестирования.