Перейти к содержанию

ADR 0001: TigerBeetle как леджер

Статус: принято. Дата: 2026. Версия TigerBeetle: 0.17.4.

На какие вопросы отвечает

  • Где «настоящий» баланс пользователя — в PostgreSQL или где-то ещё?
  • Почему деньги нельзя записать напрямую из Auth Center или PSP-адаптера?
  • Что такое two-phase перевод и зачем он нужен?
  • Что значит инвариант transit.balance = 0 и почему он так важен?
  • Почему ID счетов и переводов в TigerBeetle «детерминированные»?

Контекст

OneWallet — e-money кошелёк (Таиланд, регулятор BOT, валюта THB). Балансы и переводы — это финансовый источник правды, к нему предъявляются требования: строгая консистентность, отсутствие двойного списания, атомарность многоногих переводов (перевод + комиссии), идемпотентность при ретраях.

Обычная SQL-таблица балансов в pm.* не даёт этих гарантий «из коробки»: конкуренция, частичные сбои, ручной пересчёт сальдо — источник ошибок и расхождений. Нужен специализированный двусторонний (double-entry) леджер.

Решение

Источником правды по балансам и переводам является TigerBeetle — специализированный финансовый леджер. PostgreSQL (pm.intent, pm.tx_history, pm.intent_event) хранит бизнес-метаданные и историю, но не считает сальдо.

Ключевые принципы:

  1. Payment Manager — единственный write-клиент TigerBeetle. createAccounts / createTransfers вызываются только из PM (projects/payment-manager/src/ledger/). Admin Panel читает TB в read-only; Auth Center и PSP-адаптеры прямого доступа к TB не имеют (баланс — через nginx→PM, см. 0002-single-hmac-auth.md).

  2. Two-phase переводы. Каждое движение денег — это сначала pending-перевод (резерв), затем post (подтверждение) или void (откат). В коде: authorize() ставит TransferFlags.pending с timeout (300с = авто-void), settle() постит, отмена — войдит. Многоногие переводы (перевод + PRE/POST комиссии) связаны TransferFlags.linked — выполняются атомарно всё-или-ничего.

  3. Транзитные счета и инвариант transit.balance = 0. Перевод идёт не напрямую from→to, а через системный транзитный счёт (system.transit.{channel}.{currency}): from→transit (hold), затем transit→recipient и transit→fee. После успешного post транзитный счёт обнуляется. transit.balance = 0 в покое — главный финансовый инвариант (деньги не «зависли» и не «родились»); он гарантируется механикой TB, а не ручной проверкой.

  4. Детерминированные ID. ID счёта = uuidv5(name, TB_NS), ID перевода = uuidv5("${intentId}:${index}", TRANSFER_NS); post/void ID выводятся из pending-ID (pendingId ^ 1n / ^ 2n). Это делает операции идемпотентными: повторный запрос с тем же intentId даёт те же ID, TB отвергает дубль. trace_id = intent_id связывает событие в логах с переводом в TB.

graph LR
  F[from.wallet] -->|T0 pending hold| TR[system.transit]
  TR -->|T1 pending| R[to.wallet]
  TR -->|T2+ pending| FE[fee / revenue]
  TR -.->|после post balance=0| Z((0))

Пример

P2P-перевод 100 THB с комиссией 2 THB (operationType = P2P_TRANSFER, channel = INTERNAL, синхронный settleSync):

  • T0 pending: user.42.THB → system.transit.INTERNAL.THB, amount 102 (LINKED)
  • T1 pending: system.transit.INTERNAL.THB → user.99.THB, amount 100 (LINKED)
  • T2 pending: system.transit.INTERNAL.THB → system.revenue.THB, amount 2
  • post всех трёх → у отправителя −102, у получателя +100, revenue +2, system.transit.INTERNAL.THB = 0.

Последствия

  • Плюсы: строгая консистентность и атомарность, идемпотентность на ретраях, верифицируемость (ID детерминированы — состояние можно перепроверить без дополнительного state), невозможность «потерять» или «создать» деньги.
  • Минусы / ограничения: единственная точка записи (PM) — узкое место и зона повышенной ответственности; сальдо нельзя «починить» SQL-апдейтом, только компенсирующими переводами; TB — отдельный сервис в инфраструктуре (порт внутр. 3000, профиль compose), требует бэкапа/мониторинга.
  • Кросс-схемное правило сохраняется: PM не пишет в public.*, Serverpod не пишет в pm.* (см. 0003-schema-separation.md).

Ссылки

  • ../dev/04-payments-and-ledger.md — детали intent state machine, каналов и саги переводов.
  • 0004-single-intent-api.md — единый POST /intents.
  • 0002-single-hmac-auth.md — почему доступ только через PM+HMAC.
  • Код PM-леджера: projects/payment-manager/src/ledger/ (transfers.ts — authorize/settle/void; accounts.ts — имена и ID счетов; id-gen.ts — детерминированные ID переводов).
  • PSP-саги: projects/payment-manager/src/channels/_p2p-saga.ts.
  • TigerBeetle Two-Phase: https://docs.tigerbeetle.com/single-page#coding-two-phase-transfers