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

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

Табличный 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

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

Балансы

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

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

Проводка книги может нести expires_at. Его выставляют только реферальные вознаграждения и только когда в реферальной программе настроен reward_expiry_days — см. Реферальная программа и лояльность. Проход commerce_maintenance на воркере выполняется раз в минуту, берёт до 500 положительных клиентских проводок, срок сгорания которых прошёл и у которых ещё нет транзакции expiration, и пишет по одной компенсирующей паре на каждую. Два решения в этом проходе приняты намеренно:
  • Сгорание — это компенсирующая проводка, а не фильтр в запросе баланса. Баланс остаётся простой суммой, а само сгорание появляется в истории клиента, где он видит, что произошло с его средствами.
  • Сумма ограничивается суммой проводки, текущим балансом клиента и тем, что клиент получил после этой проводки. Клиента, который уже потратил эти средства, их сгорание не уводит в минус.
Проводка, ограниченная сумма которой получилась нулевой, всё равно получает свою транзакцию expiration — без проводок под ней, — и именно это не даёт циклу разбирать её вечно.
Этот проход выполняется только на воркере. Установка, работающая без воркера, никогда не сжигает средства в кошельке, и реферальные вознаграждения с истёкшим сроком остаются доступными для трат.

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

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

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

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

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

Начисление из доброй воли после обращения в поддержку, исправление ошибки, ручная реферальная выплата — всё это один вызов API, и только он. Ни маршрута /v1/panel, ни экрана в /admin для корректировки кошелька нет; API с bearer-токеном — вся поверхность целиком.
Что требуется и почему:
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, клиентом в качестве цели и с суммой и валютой в метаданных. После этого корректировка появляется в собственной истории кошелька клиента с её типом и причиной.

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

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