> ## 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.

# Кошелёк и книга проводок

> Модель двойной записи, стоящая за каждым балансом, почему проводки только добавляются, как сгорают средства и что оператор обязан указать, чтобы подвинуть деньги клиента

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

Такая форма выбрана по одной причине: баланс должен быть объяснимым. Сохранённое число, которое
кто-то поправил, — это число без истории; сумма движений всегда может ответить, «почему он такой и
что его изменило». Корректировки — это новые компенсирующие пары, а не правки, поэтому ответ
переживает корректировку.

## Три типа счетов

| Тип счёта | Что на нём | Указывает клиента? |
| - | - | - |
| `customer_wallet` | средства одного клиента, по каждой валюте | всегда |
| `platform_clearing` | собственный счёт платформы: сюда попадает выручка, и отсюда уходят средства, которые платформа уже держит | никогда |
| `provider_clearing` | деньги, лежащие у платёжного провайдера | никогда |

Табличный CHECK делает эти правила структурными: проводку `customer_wallet` без клиента или
клиринговую проводку с клиентом записать нельзя. Кроме того, перед вставкой любой транзакции домен
отклоняет меньше двух проводок, нулевую сумму, некорректную валюту и любую валюту, проводки которой
не дают в сумме ноль.

Деньги — это `int64`-количество минорных единиц плюс трёхбуквенный ISO-код заглавными буквами.
Плавающая точка не может точно представить 0.10, а расхождение в одну минорную единицу в книге
проводок, которая обязана сходиться в ноль, — это несбалансированная книга, поэтому ни схема, ни
домен, ни API никогда не несут десятичную сумму. Запросы и ответы используют `amountMinor` вместе с
`currency`; показатель степени валюты берётся из фиксированной таблицы, а не из зашитой сотни,
поэтому валюта без дробной части — например `XTR`, `JPY` или `KRW` — никогда не масштабируется
неверным множителем.

## Типы транзакций

`ledger_transactions.type` — закрытый набор: `credit`, `debit`, `payment`, `refund`,
`referral_reward`, `correction`, `expiration`. Каждая строка также несёт `reference_type` и
`reference_id`, называющие причину движения, `idempotency_key` и — для всего, что решил человек, —
`reason` и `actor_id`.

Две гарантии уникальности делают транзакцию ровно-однократной сразу с двух сторон:

* `idempotency_key` уникален по всей таблице;
* `(reference_type, reference_id, type)` уникальна, поэтому одна и та же причина не может породить
  вторую транзакцию того же вида даже под другим ключом.

## Все движения, которые записывает Omniflow

| Причина | Тип | Референс | Ключ идемпотентности | Проводки |
| - | - | - | - | - |
| Заказ проведён | `payment` | `order` / идентификатор заказа | `payment:{providerEventID}` либо `wallet:{orderKey}`, когда всё покрыл кошелёк | `customer_wallet −wallet`, `provider_clearing −external`, `platform_clearing +(wallet + external)` |
| Пополнение зачислено | `credit` | `wallet_topup` / идентификатор заказа | `topup:{orderID}` | `customer_wallet +received`, `provider_clearing −received` |
| Возврат прошёл успешно | `refund` | `refund` / идентификатор возврата | ключ возврата вызывающей стороны | `platform_clearing −amount`, `provider_clearing +amount` |
| Пополнение кошелька возвращено | `debit` | `refund` / идентификатор возврата | `refund-debit:{refundID}` | `customer_wallet −amount`, `platform_clearing +amount` — записывается, только если доступный баланс это покрывает |
| Корректировка оператора | тип, выбранный оператором | `operator_adjustment` / референс вызывающей стороны | заголовок `Idempotency-Key` вызывающей стороны | `customer_wallet ±amount`, `platform_clearing ∓amount` |
| Реферальное вознаграждение | `referral_reward` | `referral` / приглашённый клиент и роль | `referral:{...}` | `customer_wallet +reward` (может нести срок сгорания), `platform_clearing −reward` |
| Подарок получен как средства в кошельке | `credit` | `gift` / идентификатор подарка | `gift:{giftID}` | `customer_wallet +credit`, `platform_clearing −credit` |
| Средства в кошельке сгорели | `expiration` | `ledger_entry` / идентификатор проводки | `expiration:{entryID}` | `customer_wallet −amount`, `platform_clearing +amount` |
| Выдача цифрового товара окончательно провалилась | `refund` | `goods_order` / идентификатор заказа | `goods-refund:{orderID}` | `customer_wallet +price`, `platform_clearing −price` |

Две из них стоит прочитать внимательно.

**Получение подарка** сопоставляет средства получателя со счётом `platform_clearing`, а не с
выручкой. Деньги отправителя были признаны, когда провёлся его собственный заказ, и признание их
второй раз в момент получения подарка посчитало бы их дважды.

**Пополнение** зачисляет средства на `customer_wallet` против `provider_clearing`, потому что деньги
действительно пришли от провайдера и ещё не заработаны. Проведение заказа затем переносит их от
клиента на `platform_clearing` как выручку. Именно поэтому клиент, платящий полностью из кошелька,
всё равно порождает сбалансированное движение без провайдерского плеча.

## Балансы

Баланс — это простая сумма проводок клиента по `customer_wallet` в одной валюте. Из этой суммы
ничего не отфильтровывается — ни сгоревшие средства, ни старые проводки, — и именно это делает её
детерминированной и позволяет истории клиента сходиться с ней строка за строкой.

> [!WARNING]
>
> Более ранняя документация описывала баланс как сумму проводок с *неистёкшим* сроком. Работает это
> не так, и разница важна при аудите: средства с истёкшим сроком убираются компенсирующей
> отрицательной проводкой, которую пишет фоновый цикл, поэтому баланс сходится к ожидаемой цифре
> после того, как этот цикл отработал, а не в тот момент, когда наступает время сгорания.

Клиентские экраны показывают три цифры на каждую валюту, потому что одно число не ответило бы
честно ни на один из вопросов:

| Цифра | Значение |
| - | - |
| Всего | сколько держит книга проводок |
| Зарезервировано | сколько уже заняли собственные заказы клиента в состояниях `draft` и `pending` |
| Доступно | сколько новый заказ действительно может потратить |

Зарезервированное вычитается той же арифметикой, что используется на пути заказа, — буквально той
же: один запрос (`GetWalletBalances`, доступный как `commercepg.Store.WalletBalances` и
`AvailableWalletBalance`) читают и создание заказа, и магазин, и возврат пополнения, и веб-панель
клиента. Без резерва два
оформления заказа, открытых рядом, оба считали бы одни и те же деньги своими. Клиенту, которому
показали только общую сумму, а затем при оформлении дали меньшую, дважды назвали неверное число.

## Сгорание средств

Проводка книги может нести `expires_at`. Его выставляют только реферальные вознаграждения и только
когда в реферальной программе настроен `reward_expiry_days` — см. [Реферальная программа и
лояльность](https://omniflow-vpn.mintlify.site/ru/commerce/referrals-loyalty.md).

Проход `commerce_maintenance` на воркере выполняется раз в минуту, берёт до 500 положительных
клиентских проводок, срок сгорания которых прошёл и у которых ещё нет транзакции `expiration`, и
пишет по одной компенсирующей паре на каждую.

Два решения в этом проходе приняты намеренно:

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

Проводка, ограниченная сумма которой получилась нулевой, всё равно получает свою транзакцию
`expiration` — без проводок под ней, — и именно это не даёт циклу разбирать её вечно.

> [!NOTE]
>
> Этот проход выполняется только на воркере. Установка, работающая без воркера, никогда не сжигает
> средства в кошельке, и реферальные вознаграждения с истёкшим сроком остаются доступными для трат.

## Что видит клиент

* **Веб.** `/account/wallet` показывает три цифры на каждую валюту, элементы управления
  пополнением и историю собственных проводок клиента с курсорной пагинацией — с типом транзакции
  и, если корректировка её несла, с причиной оператора. API — `GET /v1/account/wallet`.
* **Telegram.** **Кошелёк** показывает баланс и последние десять движений со знаковыми суммами, а
  **Пополнить** начинает заказ на пополнение.

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

Пополнение кошелька описано на странице [Пополнение кошелька и сохранённые
корзины](https://omniflow-vpn.mintlify.site/ru/commerce/wallet-topup-cart.md); трата средств — в разделе о списании из кошелька до
внешнего платежа на странице [Заказы, платежи и возвраты](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md).

## Что видит оператор

`/admin/customers/{customerId}` → **Кошелёк** читает
`GET /v1/panel/customers/{customerID}/wallet`, и право доступа здесь — **`finance.read`**, а не
`customers.read`. Разделение намеренное: оператор поддержки может видеть, что у клиента есть баланс,
не имея возможности прочитать каждое движение, которое его сформировало.

Вкладка кошелька в панели работает только на чтение. Ни в одном месте панели нет пути обновления или
удаления строки книги проводок и нет маршрута панели, который меняет баланс.

## Корректировки оператора

Начисление из доброй воли после обращения в поддержку, исправление ошибки, ручная реферальная
выплата — всё это один вызов API, и только он. Ни маршрута `/v1/panel`, ни экрана в `/admin` для
корректировки кошелька нет; API с bearer-токеном — вся поверхность целиком.

```bash theme={null}
curl -X POST https://example.com/v1/admin/customers/{customerID}/wallet-adjustments \
  -H "Authorization: Bearer $APP_OPERATOR_TOKEN" \
  -H "Idempotency-Key: goodwill-2026-08-13-0007" \
  -H "Content-Type: application/json" \
  -d '{"amountMinor": 50000, "currency": "RUB", "type": "credit",
       "reference": "ticket-4821", "reason": "Outage compensation",
       "operatorID": "ops@example.com"}'
```

Что требуется и почему:

**1. Причина, действующее лицо и ненулевая сумма**

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

**2. Тип с правильным знаком**

`credit`, `debit`, `referral_reward`, `correction` или `expiration`. `credit` и
`referral_reward` должны быть положительными; `debit` и `expiration` — отрицательными;
`correction` может быть любым. Начисление, записанное с отрицательной суммой, — это опечатка, а
не намерение.

**3. Ключ идемпотентности**

Переиспользование ключа с другим типом, референсом, действующим лицом, клиентом, валютой или
суммой отклоняется с «idempotency key was already used with different wallet parameters».
Переиспользование с теми же параметрами возвращает ответ, не записывая вторую транзакцию,
поэтому повторённый запрос безопасен.

**4. Баланс, остающийся неотрицательным**

Отрицательная корректировка перечитывает баланс внутри сериализуемой транзакции и отказывает с
«wallet adjustment would create a negative balance», вместо того чтобы записать списание,
которое клиент не может покрыть.

При успехе записываются транзакция и обе проводки, а в журнал аудита добавляется событие
`wallet.adjusted` с категорией `financial`, исходом `success`, клиентом в качестве цели и с суммой и
валютой в метаданных. После этого корректировка появляется в собственной истории кошелька клиента с
её типом и причиной.

## Хранение и приватность

Финансовую историю никогда не удаляет чистка по срокам хранения, и анонимизация клиента её
сохраняет: заказ, породивший проводку в книге, нельзя удалить, не разбалансировав книгу.
Анонимизация убирает контактные и идентификационные данные, а не движения, которые объясняют баланс.
См. [Приватность клиента](https://omniflow-vpn.mintlify.site/ru/customer/privacy.md) и [Финансовые
инварианты](https://omniflow-vpn.mintlify.site/ru/commerce/financial-invariants.md).


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