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

Прочитайте её, прежде чем менять что-либо в `internal/commerce`, `internal/commercepg`,
`internal/paymentservice` или в файле `database/migrations`, который затрагивает заказ, платёж или
книгу проводок.

## Обеспечивается схемой

Это CHECK-ограничения и уникальные ограничения в PostgreSQL. Нарушение любого из них вызывает
ошибку, а не производит неверное число.

| Инвариант | Где | Что предотвращает |
| - | - | - |
| `wallet_minor + external_minor = subtotal_minor - discount_minor` | CHECK в `orders` | Заказ, части которого не сходятся: списанные деньги, которых не объясняет ни одна строка, или удержание из кошелька, которого никто не был должен |
| `discount_minor <= subtotal_minor` | CHECK в `orders` | Скидку больше цены, которая сделала бы сумму к оплате отрицательной |
| `refunded_minor <= paid_minor` | CHECK в `orders` | Возврат большего, чем когда-либо было взято |
| Каждая сумма — `bigint` в минорных единицах, и каждая валюта соответствует `^[A-Z]{3}$` | `orders`, `ledger_entries`, `payment_intents`, `refunds` | Расхождение из-за плавающей точки и сумму, валюту которой никто не может определить |
| Один заказ на `(user_id, idempotency_key)` | UNIQUE в `orders` | Повторное подтверждение, двойное нажатие или три одновременных срабатывания корзины, создающие три заказа |
| Одно платёжное намерение на `(provider, idempotency_key)` | UNIQUE в `payment_intents` | Повторный запуск платежа, открывающий вторую страницу оплаты для той же попытки |
| Одно намерение на `(provider, provider_reference)` | частичный UNIQUE-индекс | Два намерения, претендующих на один и тот же платёж провайдера, — так вебхук не может провести не то |
| Одно платёжное событие на `(payment_intent_id, provider_event_id, type)` | `payment_events` UNIQUE NULLS NOT DISTINCT | Одно и то же событие провайдера, дважды добавляющее одну и ту же историю |
| Одна строка вебхука на `(provider, provider_event_id)` | UNIQUE в `provider_webhook_events` | Повторную обработку переотправленного вебхука — это и есть сама защита от дублей |
| Один возврат на `(payment_intent_id, idempotency_key)` | UNIQUE в `refunds` | Повторный запрос возврата, возвращающий деньги дважды |
| Одно ручное решение на намерение | UNIQUE в `manual_payment_approvals` | Двух операторов, подтверждающего и отклоняющего один и тот же перевод |
| Одна транзакция книги проводок на `idempotency_key` **и** одна на `(reference_type, reference_id, type)` | `ledger_transactions` UNIQUE ×2 | Двойное начисление пополнения, реферального вознаграждения или подарка — сразу с двух независимых сторон |
| Проводка `customer_wallet` всегда называет клиента; клиринговая проводка — никогда | CHECK в `ledger_entries` | Средства, не принадлежащие никому, и деньги платформы, приписанные клиенту |
| `amount_minor <> 0` в каждой проводке книги | CHECK в `ledger_entries` | Нулевые проводки, добивающие транзакцию до видимости баланса |
| Пополнение считается зачисленным ровно тогда, когда у него есть транзакция книги проводок | CHECK в `wallet_topups` | Кошелёк, пополненный без движения за этим, или движение, о котором пополнение не знает |
| Одна открытая корзина на клиента | частичный UNIQUE-индекс в `carts` | Две сохранённые покупки, соревнующиеся за один и тот же баланс |
| `status = 'purchased'` ⟺ `order_id IS NOT NULL` | CHECK в `carts` | Корзину, которая утверждает, что что-то купила, но не может это назвать |
| Одно открытое оформление заказа на клиента | частичный UNIQUE-индекс в `bot_checkout_sessions` | Оформление в браузере и оформление в чате, подтверждающие два разных заказа из одного намерения |
| Один пробный период на клиента | первичный ключ `trial_claims` | Второй бесплатный пробный период, запрошенный в той же транзакции, что и заказ, которому он принадлежит |
| Регулярные платежи нельзя включить без пройденной проверки возможностей | CHECK в `payment_provider_settings` | Взведение автоматических списаний на интеграции, которая на самом деле не умеет привязывать способ оплаты |
| Состояние заказа, операция заказа, статус намерения, статус возврата, тип платёжного события, тип транзакции книги проводок и тип счёта книги проводок — закрытые наборы | CHECK на каждом столбце | Попадание неизвестного значения в автомат состояний, который не знает, как его прочитать |

## Обеспечивается внутри транзакции

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

| Инвариант | Как | Что предотвращает |
| - | - | - |
| Средства кошелька, занятые заказами в `draft` и `pending`, недоступны новому заказу | Доступный баланс вычитает резерв прямо в SQL | Два оформления заказа, открытых рядом, каждое из которых считает одни и те же деньги своими |
| Кошелёк перепроверяется при проведении платежа | Баланс перечитывается под блокировкой заказа; нехватка становится `wallet_unavailable`, и заказ не проводится | Выдачу права на услугу, частично оплаченного средствами, которые с тех пор были потрачены |
| Баланс кошелька никогда не уходит в минус | Корректировки оператора перечитывают баланс и отказывают; сгорание средств ограничивает сумму тем, что ещё осталось | Списание или сгорание, уводящее клиента ниже нуля |
| Изменения возвратов выстраиваются в очередь по каждому намерению, а ожидающие плюс успешные возвраты не могут превысить намерение | Рекомендательная блокировка на намерении и проверка резерва **до** обращения к провайдеру | Два параллельных возврата, каждый из которых проходит проверку, обесцененную другим |
| Остаток использований акции определяется под блокировкой строки | `FOR UPDATE` на акции во время расчёта цены | Двух клиентов, обоих гасящих последнее использование ограниченного кода |
| Два изменения одной подписки никогда не строятся на одном и том же окончании | Проведение, получение подарка и погашение кода выполняются на уровне `Serializable` и читают текущее право на услугу `FOR UPDATE`; проигравшее гонку погашение повторяется | Код, погашенный во время проведения продления, молча теряет один из двух периодов |
| Лимиты одновременности вычисляются под рекомендательной блокировкой на клиента | `pg_advisory_xact_lock` на клиенте перед подсчётом | Две одновременные покупки, обе проходящие потолок «одна подписка» |
| Успех платежа и надёжное намерение на исполнение фиксируются вместе | Проведение записывает движение в книге проводок, право на услугу, операцию исполнения, операторское уведомление и реферальное вознаграждение в одной транзакции | Взятые деньги без записи о том, что что-то причитается |
| Дублирующие, несовпадающие, переплаченные и недоплаченные события провайдера никогда не дают права на услугу | Классификация происходит до любой смены состояния, а непроводящие классы фиксируют только событие | Некорректный или повторно доставленный обратный вызов провайдера, чеканящий доступ |
| Точный опоздавший платёж проводится не более одного раза | Проверка на дубликат внутри классификации | Двойное применение платежа, пришедшего после истечения срока |
| Пропорциональное списание за дополнение никогда не превышает его каталожную цену, никогда не округляется до нуля и считается в 128 битах | Арифметика домена | Переполнение на длинном периоде или бесплатное дополнение из-за правила округления |
| Ёмкость дополнения применяется не более одного раза на заказ и версию дополнения, а лимит null остаётся null | Уникальность `entitlement_addons` и арифметика, сохраняющая null | Удвоение ёмкости клиента при повторном проведении или превращение «безлимита» в число |
| Сохранённая корзина переоценивается по текущему каталогу перед любым списанием | Расчёт корзины всегда заново выполняет предпросмотр | Списание цены, которой каталог больше не предлагает |
| Режим обслуживания блокирует только новые заказы и исходящую выдачу доступа | Запрет покупок проверяется в самом начале каждого создателя заказа | Установку, которая не может выдавать доступ и при этом продолжает продавать, — при том что уже взятые деньги всё равно проводятся, возвраты работают, а история остаётся читаемой |
| Чистка по срокам хранения удаляет только расходуемые операционные данные | Собственная область действия воркера хранения | Чистку, удаляющую финансовую историю, журнал аудита, согласия, идентификационные данные или историю прав на услуги |
| Секреты, сырые заголовки авторизации, ссылки на подписку и контактные данные в открытом виде не попадают в логи и в операционную историю | Заголовки вебхуков сокращаются до `content-type` и `user-agent`; учётные данные провайдеров запечатываются ключом `APP_DATA_ENCRYPTION_KEY` и никогда не возвращаются API | Попадание учётных данных провайдера в дамп базы или в диагностический экран |

## Только добавление — точная формулировка

«Только добавление» стоит сформулировать точно, потому что три таблицы, к которым это относится,
ведут себя по-разному:

* **Транзакции и проводки книги** никогда не обновляются и никогда не удаляются. Ни в панели, ни в
  API, ни в воркерах нет пути кода, который делает то или другое. Корректировка — это новая
  компенсирующая пара.
* **Платёжные события** только вставляются. История намерения — это упорядоченный список всего, что
  когда-либо было по нему классифицировано, включая классификации, которые ничего не изменили.
* **События вебхуков провайдеров** вставляются один раз на `(provider, provider_event_id)`, и
  дальше у них только завершается статус обработки. Они несут `retain_until` в 30 дней, после чего
  чистка по срокам хранения их удаляет: сырое тело — это диагностические данные, а не финансовая
  история.

## Что намеренно не гарантируется

Описывать проверки, не описывая их границы, значило бы вводить в заблуждение.

* **Единого шлюза, через который проходит каждый переход заказа, нет.** Домен объявляет таблицу
  переходов, но в продакшене её никто не вызывает. Работает набор предикатов на отдельных запросах:
  запрос отмены, чистка истёкших заказов, проверка на дубликат в классификации. Добавить новый код,
  пишущий `orders.state`, — значит добавить и его собственную проверку, а не рассчитывать на уже
  существующую.
* **`wallet_unavailable` оставляет работу человеку.** Провайдер взял деньги по заказу, который
  остаётся в `pending`, и ничто не разрешает это автоматически. См. [Когда платёж не
  проводится](https://omniflow-vpn.mintlify.site/ru/commerce/orders-payments.md).
* **Сгорание средств в кошельке сходится, а не применяется мгновенно.** Баланс — это простая сумма
  проводок; сгорание — компенсирующая проводка, которую раз в минуту пишет проход
  `commerce_maintenance` на воркере. Пока он не отработал, средства с истёкшим сроком по-прежнему можно потратить.
* **Кнопки возврата и сверки в панели фиксируют решение.** Они записывают `panel.refund.issued` и
  `panel.payment.reconciled` и не двигают деньги и не опрашивают провайдера. Возврат, который
  действительно возвращает деньги, — это `POST /v1/admin/payments/{paymentID}/refunds`.
* **Уникальное ограничение на `ledger_entries` не может дедуплицировать клиринговые строки.** Оно
  покрывает `(transaction_id, account_type, user_id, currency)`, а у обоих клиринговых типов счетов
  `user_id` равен NULL, который PostgreSQL в уникальном ограничении считает различным значением.
  Клиринговые строки полностью полагаются на то, что их транзакция создаётся один раз под своим
  уникальным ключом идемпотентности, — поэтому каждый пишущий код сначала создаёт транзакцию и
  вставляет проводки только под неё.
* **Возврат за выдачу цифрового товара зачисляет расчётную цену**, не вычитая скидку по акции, и
  оставляет `orders.state` и `orders.refunded_minor` нетронутыми. Клиенту, который применил
  промокод к неудавшейся выдаче в магазине, зачисляется больше, чем он заплатил, а заказ по-прежнему
  читается как оплаченный.

## Список, блокирующий релиз

Изменение, ломающее любой из этих пунктов, не должно попадать в релиз:

1. Деньги — это целая сумма в минорных единицах плюс одна валюта из трёх заглавных букв.
2. Заказ удовлетворяет `wallet + external = subtotal - discount`.
3. Скидка не может превышать подытог; возвращённая сумма не может превышать оплаченную.
4. Транзакция книги проводок сходится в ноль независимо по каждой валюте.
5. Баланс кошелька выводится из проводок, которые только добавляются, и ни один путь кода не пишет
   движение, которое увело бы его ниже нуля.
6. Кошельковые резервы ожидающих оплаты заказов вычитаются из доступного баланса и перепроверяются
   перед проведением платежа.
7. Каждое событие провайдера, каждое изменение заказа, каждый возврат, каждый референс книги
   проводок и каждая операция исполнения идемпотентны под устойчивым ключом.
8. Дублирующие, несовпадающие, переплаченные и недоплаченные события провайдера никогда не дают
   права на услугу; точный опоздавший платёж может дать его не более одного раза.
9. Успех платежа и надёжное намерение на исполнение фиксируются вместе.
10. История книги проводок, платёжных событий, согласий, аудита и исполнения только пополняется;
    тела вебхуков хранятся 30 дней и затем удаляются.
11. Секреты, ссылки на подписку, контактные данные в открытом виде и сырые заголовки авторизации
    никогда не попадают в логи или в операционную историю.
12. Изменения возвратов сериализуются по каждому платёжному намерению, и ожидающие плюс успешные
    возвраты не могут превысить оплаченную провайдером сумму до обращения к провайдеру.
13. Пополнение кошелька зачисляет ровно ту сумму, которую провёл провайдер, ровно один раз, с
    ключом по своему заказу. Пополнение никогда не тратит средства кошелька.
14. Пропорциональное списание за дополнение никогда не превышает его каталожную цену, никогда не
    округляется до нуля и считается в 128 битах, чтобы длинный период не мог его переполнить.
15. Ёмкость дополнения применяется к праву на услугу не более одного раза на заказ и версию
    дополнения, а лимит null остаётся null.
16. Сохранённая корзина переоценивается по текущему каталогу перед любым списанием и списывается под
    одним ключом идемпотентности, сколько бы путей ни пытались это сделать одновременно.
17. Лимиты одновременности вычисляются под рекомендательной блокировкой на клиента, поэтому
    одновременные покупки не могут пройти обе.
18. Режим обслуживания блокирует только новые заказы и исходящую выдачу доступа. Он никогда не
    отменяет, не возвращает, не аннулирует и иным образом не меняет уже взятые деньги.
19. Чистка по срокам хранения удаляет только расходуемые операционные данные. Финансовая история,
    журнал аудита, согласия, идентификационные данные и история прав на услуги никогда не удаляются
    чисткой.

## Где они проверяются тестами

Тесты домена покрывают разделение суммы с приоритетом кошелька, повтор платежа, несовпадение валют,
область действия акции, балансировку книги проводок, подписи вебхуков, проверки жизненного цикла
клиента, лимиты и проведение пополнений, пропорциональный расчёт дополнений, вычисление корзины,
политику одновременности и разрешение сквадов. Они выполняются в обычном `go test ./...`.

Набор на Testcontainers в `internal/integrationtest` доказывает транзакционные инварианты на
настоящем PostgreSQL: ровно однократное зачисление пополнения при повторах, разрешение переплаты и
недоплаты в книге проводок, однократное списание корзины при конкуренции, соблюдение лимитов
одновременности под нагрузкой, одно событие outbox и одно операторское уведомление на проведение,
блокировку новых заказов режимом обслуживания при том, что уже оплаченный заказ проводится, и
однократное применение дополнений. См. [Стратегия тестирования](https://omniflow-vpn.mintlify.site/ru/contributing/testing.md).

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


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