Skip to main content
Денежные правила Omniflow — не соглашения, которые ревьюеры держат в голове. Большинство из них — табличные ограничения и уникальные индексы, а значит, ни скрипт, ни бэкфилл, ни будущий код, ни ошибка в миграции не смогут записать состояние, которое эти правила запрещают. Эта страница перечисляет их, говорит, где живёт каждое, и что пошло бы не так без него. Прочитайте её, прежде чем менять что-либо в internal/commerce, internal/commercepg, internal/paymentservice или в файле database/migrations, который затрагивает заказ, платёж или книгу проводок.

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

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

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

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

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

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

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

Описывать проверки, не описывая их границы, значило бы вводить в заблуждение.
  • Единого шлюза, через который проходит каждый переход заказа, нет. Домен объявляет таблицу переходов, но в продакшене её никто не вызывает. Работает набор предикатов на отдельных запросах: запрос отмены, чистка истёкших заказов, проверка на дубликат в классификации. Добавить новый код, пишущий orders.state, — значит добавить и его собственную проверку, а не рассчитывать на уже существующую.
  • wallet_unavailable оставляет работу человеку. Провайдер взял деньги по заказу, который остаётся в pending, и ничто не разрешает это автоматически. См. Когда платёж не проводится.
  • Сгорание средств в кошельке сходится, а не применяется мгновенно. Баланс — это простая сумма проводок; сгорание — компенсирующая проводка, которую раз в минуту пишет проход 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 и одно операторское уведомление на проведение, блокировку новых заказов режимом обслуживания при том, что уже оплаченный заказ проводится, и однократное применение дополнений. См. Стратегия тестирования. Смежное чтение: Заказы, платежи и возвраты, Кошелёк и книга проводок, Пополнение кошелька и сохранённые корзины.