Пополнение — это обычный заказ
Пополнение — это заказ сoperation = 'topup'. Он получает платёжное намерение, оформление у
провайдера, приём вебхуков, сверку и обработку возвратов ровно так же, как покупка подписки.
Отличается то, что производит расчёт: средства на кошельке клиента вместо права на услугу.
wallet_minor всегда равен нулю — и, поскольку кошельку нечего покрывать,
пополнение всегда создаётся в состоянии pending с провайдером, которому надо заплатить.
Рядом с заказом существует одна строка wallet_topups, хранящая запрошенную сумму, зачисленную сумму
и проводочную транзакцию, которая её зачислила. Её собственный CHECK связывает две последние вместе:
пополнение зачислено ровно тогда, когда у него есть проводочная транзакция, — никогда одно без
другого.
Как оно создаётся
Хранилище выполняет всё создание в сериализуемой транзакции:- Идемпотентность. Существующий заказ под ключом вызывающей стороны с другой операцией, валютой или суммой получает отказ; тот же самый — возвращается.
- Обслуживание. Пополнение — это покупка, поэтому оно получает отказ
503 maintenance_active, пока установка находится в окне обслуживания. См. Режим обслуживания. - Допуск по окну. Суммируется то, что клиенту фактически зачислили внутри скользящего окна.
- Лимиты. Запрошенная сумма проверяется на соответствие настроенным границам.
- Заказ и строка
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 и остаётся сохранённой, чтобы её можно было пересобрать, вместо списания по
устаревшей цене. Промокод, переставший быть действительным, сообщается как отказ, и корзина
оценивается без него.
Автоматическая покупка
При включённой автоматической покупке корзина списывается, как только кошелёк её покрывает. Решение вычисляется в фиксированном порядке — автоматическая покупка выключена, затем истёк срок, затем режим обслуживания, затем нехватка баланса, — поэтому причина, которую показывают клиенту, это первая, что действительно применима. Попытку запускают три вещи:- Рассчитанное пополнение — сразу после фиксации его транзакции.
- Ежеминутный проход, который повторяет до 200 корзин с автоматической покупкой, принадлежащих клиентам, у которых есть история кошелька в валюте корзины. Именно это делает так, что реферальная награда или коррекция от оператора тоже освобождают корзину.
- Нажатие клиентом 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 отбрасывает её.
Корзины магазина никогда не покупают себя сами
Сохранённая покупка цифровых товаров хранится с выключенной автоматической покупкой и пропускается проходом. Цена в магазине — это котировка поставщика, истекающая за минуты, поэтому списание по ней без участия клиента списало бы число, которое клиент последний раз видел несколько дней назад. Полное обоснование — на странице Подарки и цифровые товары. У клиента одна открытая корзина, поэтому сохранение позиции магазина заменяет сохранённый тариф и наоборот.Что настраивает оператор
Лимиты пополнения и время жизни корзины — это переменные окружения, читаемые при старте процесса.
Старт отказывает конфигурации, чей ненулевой максимум ниже минимума, потому что такая комбинация может
только отклонять любую сумму. Полный справочник переменных —
Конфигурация.
Инварианты, стоящие за обеими функциями
- Пополнение зачисляет ровно ту сумму, которую рассчитал провайдер, ровно один раз, с ключом по своему заказу, и никогда не тратит средства кошелька.
- Пополнение зачислено ровно тогда, когда оно несёт проводочную транзакцию, — это CHECK в таблице, а не договорённость.
- Сохранённая корзина переоценивается по текущему каталогу перед любым списанием и списывается под одним ключом идемпотентности, сколько бы путей ни пытались это сделать одновременно.
- Одна открытая корзина на клиента и одно открытое оформление заказа на клиента.