> ## Documentation Index
> Fetch the complete documentation index at: https://omniflow-vpn.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Пополнение кошелька и сохранённые корзины

> Пополнение по инициативе клиента, отложенная покупка, точные лимиты, которые настраивает оператор, и инварианты, удерживающие оба механизма в безопасности

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

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

Пополнение — это заказ с `operation = 'topup'`. Он получает платёжное намерение, оформление у
провайдера, приём вебхуков, сверку и обработку возвратов ровно так же, как покупка подписки.
Отличается то, что производит расчёт: средства на кошельке клиента вместо права на услугу.

```text theme={null}
customer picks an amount
  → order (operation=topup, wallet_minor=0, external_minor=amount)
  → payment intent with the chosen provider
  → provider settles
  → ledger: provider_clearing −received, customer_wallet +received
```

Пополнение никогда не тратит средства кошелька. Финансировать кошелёк из того же кошелька было бы
замкнутым кругом, поэтому `wallet_minor` всегда равен нулю — и, поскольку кошельку нечего покрывать,
пополнение всегда создаётся в состоянии `pending` с провайдером, которому надо заплатить.

Рядом с заказом существует одна строка `wallet_topups`, хранящая запрошенную сумму, зачисленную сумму
и проводочную транзакцию, которая её зачислила. Её собственный CHECK связывает две последние вместе:
пополнение зачислено ровно тогда, когда у него есть проводочная транзакция, — никогда одно без
другого.

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

Хранилище выполняет всё создание в сериализуемой транзакции:

1. **Идемпотентность.** Существующий заказ под ключом вызывающей стороны с другой операцией, валютой
   или суммой получает отказ; тот же самый — возвращается.
2. **Обслуживание.** Пополнение — это покупка, поэтому оно получает отказ `503 maintenance_active`,
   пока установка находится в окне обслуживания. См. [Режим
   обслуживания](https://omniflow-vpn.mintlify.site/ru/operations/maintenance-mode.md).
3. **Допуск по окну.** Суммируется то, что клиенту фактически зачислили внутри скользящего окна.
4. **Лимиты.** Запрошенная сумма проверяется на соответствие настроенным границам.
5. **Заказ и строка `wallet_topups`** записываются с платёжным окном в один час, если вызывающая
   сторона не задала своё и не назвала провайдера `manual`, которому даётся семьдесят два часа, — см.
   [окно оплаты](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md#окно-оплаты).

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

Зачисление — это одна проводочная транзакция, ключом которой служит заказ:

```text theme={null}
reference_type  = 'wallet_topup'
reference_id    = <order id>
idempotency_key = 'topup:<order id>'
```

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

Как только зачисление зафиксировано, пишется событие outbox `wallet.credited` и ставится в очередь
уведомление оператору вида `topup`, дедуплицированное по заказу.

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

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

Поэтому Omniflow зачисляет ровно то, что рассчитал провайдер, и помечает заказ оплаченным:

| Получено | Зачислено | Состояние заказа | Классификация |
| - | - | - | - |
| Равно запросу | Запрошенное | `paid` | `paid` |
| Больше запроса | Полученная сумма | `paid` | `overpayment` |
| Меньше запроса | Полученная сумма | `paid` | `underpayment` |

Расхождение записывается как платёжное событие и попадает в операторскую тему пополнений, поэтому оно
видно без того, чтобы о нём сообщал клиент. См. [Классификацию
платежей](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md).

### Лимиты

Применяются три настраиваемые оператором границы, все в минимальных единицах валюты расчёта:

* минимум, который никогда не бывает ниже одной минимальной единицы, поэтому пополнение на нулевую
  сумму создать нельзя;
* максимум для одного пополнения, который значение `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` и доступных
провайдеров, — поэтому экрану никогда не приходится гадать, что примет сервер.

> [!NOTE]
>
> Если заказ создан, а провайдер затем отказал в платёжном намерении, API всё равно возвращает заказ.
> Заказ существует и принадлежит клиенту; отбросить его означало бы оставить строку, до которой никто
> не может добраться. Клиент может снова начать по нему оплату со страницы заказа.

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

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

**History** перечисляет последние десять попыток с состоянием, которого достигла каждая, — собственное
состояние заказа или `credited`, как только записана проводка зачисления. Попытка, всё ещё
находящаяся в ожидании, ведёт прямо к своему заказу — на тот же экран восстановления, что использует
покупка подписки, включая обновление и объяснение про дублирующий платёж.

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

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

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

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

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

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

Попытку запускают три вещи:

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

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

> [!WARNING]
>
> Проход выполняется внутри прохода `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` означает, что корзина никогда не
может утверждать, что купила то, чего не может назвать.

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

### Отказы

Автоматическая покупка отклоняется, а не падает с ошибкой, и всегда говорит почему:

| Причина | Значение |
| - | - |
| `cart_insufficient_balance` | Кошелёк пока не покрывает перепроверенную сумму |
| `cart_plan_unavailable` | Тариф или дополнение архивированы, выведены из продажи или без цены |
| `cart_price_changed` | Заказ получил отказ по текущей цене |
| `cart_expired` | Окно хранения корзины истекло |
| `cart_auto_purchase_off` | Клиент выключил автоматическую покупку |
| `cart_maintenance` | Установка находится в обслуживании и не принимает новых заказов |

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

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

Сохранённые корзины — функция Telegram; в веб-панели элемента управления сохранением сегодня нет.

* **Save** появляется на экране оплаты, когда баланса не хватает, и сохраняет с включённой
  автоматической покупкой.
* **Cart** показывает перепроверенную цену, чего ещё не хватает и сколько времени осталось.
* **Auto-purchase on/off** переключает списание без участия клиента, не отбрасывая корзину.
* **Buy now** списывает немедленно.
* **Clear** отбрасывает её.

Корзина, чей тариф или дополнение исчезли, отрисовывает экран «устарело» с кнопкой **Clear**, а не
цену, которую она не может вычислить.

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

Сохранённая покупка цифровых товаров хранится с выключенной автоматической покупкой и пропускается
проходом. Цена в магазине — это котировка поставщика, истекающая за минуты, поэтому списание по ней
без участия клиента списало бы число, которое клиент последний раз видел несколько дней назад. Полное
обоснование — на странице [Подарки и цифровые
товары](https://omniflow-vpn.mintlify.site/ru/commerce/gifts-and-goods.md).

У клиента одна открытая корзина, поэтому сохранение позиции магазина заменяет сохранённый тариф и
наоборот.

> [!WARNING]
>
> Рассчитанное пополнение просит открытую корзину клиента купить себя, не проверяя, какого рода эта
> корзина. Когда это корзина магазина, попытка падает с `cart_plan_unavailable` и увеличивает счётчик
> попыток на корзине, которая никогда и не должна была списываться без участия клиента. Ничего не
> куплено и ничего не списано; то, что вы видите, — это сбой, записанный на корзину, которая ведёт
> себя правильно.

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

Лимиты пополнения и время жизни корзины — это переменные окружения, читаемые при старте процесса.

| Переменная | По умолчанию | Назначение |
| - | - | - |
| `APP_WALLET_TOPUP_ENABLED` | `true` | Предлагать ли пополнение по инициативе клиента вообще |
| `APP_WALLET_TOPUP_PRESETS` | `10000,30000,50000,100000` | Суммы в одно нажатие, в минимальных единицах |
| `APP_WALLET_TOPUP_MINIMUM_MINOR` | `10000` | Наименьшее одиночное пополнение |
| `APP_WALLET_TOPUP_MAXIMUM_MINOR` | `5000000` | Наибольшее одиночное пополнение; `0` убирает потолок |
| `APP_WALLET_TOPUP_WINDOW_LIMIT_MINOR` | `10000000` | Сколько можно зачислить внутри окна; `0` отключает |
| `APP_WALLET_TOPUP_WINDOW_HOURS` | `24` | Длина скользящего окна |
| `APP_CART_TTL_HOURS` | `720` | Сколько живёт сохранённая корзина (30 дней) |

Старт отказывает конфигурации, чей ненулевой максимум ниже минимума, потому что такая комбинация может
только отклонять любую сумму. Полный справочник переменных —
[Конфигурация](https://omniflow-vpn.mintlify.site/ru/getting-started/configuration.md).

> [!WARNING]
>
> `/admin/settings` показывает карточку **Wallet top-up** с теми же полями, и её сохранение пишет
> строку `commerce_settings` и событие аудита — но лимиты, которые действительно применяет хранилище,
> берутся из переменных окружения выше. Ничто не читает `commerce_settings` обратно в путь заказа,
> поэтому изменение чисел в панели меняет то, что показывает эта страница, и больше ничего. Настраивайте
> лимиты пополнения в окружении и перезапускайте затронутые процессы.

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

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

Они перечислены вместе со всем остальным на странице [Финансовые
инварианты](https://omniflow-vpn.mintlify.site/ru/commerce/financial-invariants.md), а транзакционные из них доказываются против настоящего
PostgreSQL в наборе интеграционных тестов: зачисление пополнения ровно один раз при повторах,
разрешение переплат и недоплат в книге проводок и списание по корзине ровно один раз при
конкурентности. См. [Стратегию тестирования](https://omniflow-vpn.mintlify.site/ru/contributing/testing.md).


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.