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