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) хранит бизнес-метаданные и историю, но не считает сальдо.
Ключевые принципы:
-
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). -
Two-phase переводы. Каждое движение денег — это сначала
pending-перевод (резерв), затемpost(подтверждение) илиvoid(откат). В коде:authorize()ставитTransferFlags.pendingсtimeout(300с = авто-void),settle()постит, отмена — войдит. Многоногие переводы (перевод + PRE/POST комиссии) связаныTransferFlags.linked— выполняются атомарно всё-или-ничего. -
Транзитные счета и инвариант
transit.balance = 0. Перевод идёт не напрямуюfrom→to, а через системный транзитный счёт (system.transit.{channel}.{currency}):from→transit(hold), затемtransit→recipientиtransit→fee. После успешногоpostтранзитный счёт обнуляется.transit.balance = 0в покое — главный финансовый инвариант (деньги не «зависли» и не «родились»); он гарантируется механикой TB, а не ручной проверкой. -
Детерминированные 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