TigerBeetle — интеграция¶
TigerBeetle (TB) — высокопроизводительный финансовый ledger, единственный источник истины по балансам и движениям средств в OneWallet. Payment Manager (PM) — единственный сервис, имеющий write-доступ к TB. Все остальные сервисы взаимодействуют с TB только опосредованно: через POST /intents (write) или через read-only SDK (Admin Panel).
1. Назначение¶
TigerBeetle выступает в роли двухкнижного ledger-а (double-entry bookkeeping) для всех финансовых операций OneWallet:
- хранит аккаунты — пользовательские кошельки, мерчантские кошельки, мерчантские settlement-счета, агентские кошельки, системные транзитные счета, revenue, nostro, equity;
- хранит переводы (transfers) — атомарные изменения балансов в виде debit/credit пары;
- гарантирует финансовые инварианты на уровне БД:
debits_must_not_exceed_creditsдля пользовательских/мерчантских/агентских/сервисных кошельков (нельзя уйти в минус);- атомарность linked-батчей (или все переводы применяются, или ни один);
- детерминированный возврат балансов на pending/post/void.
Главный финансовый инвариант системы — transit.balance = 0: на любом транзитном системном аккаунте (system.transit.*) сумма debit равна сумме credit в любой момент времени между завершёнными интентами. Эта инвариантность гарантируется самим TB (через atomic linked batch на этапе settle/void) — PM лишь корректно собирает батчи.
2. SDK¶
PM использует официальный Node.js клиент TigerBeetle:
| Пакет | Версия | Источник |
|---|---|---|
tigerbeetle-node |
0.17.4 |
package.json → dependencies |
Версия зафиксирована точно (без ^) — даунгрейд/апгрейд требует ручной проверки совместимости форматов сообщений с кластером.
2.1 Подключение¶
Единая точка инициализации клиента — src/shared/tb.ts. Реализует singleton-паттерн (getTb() возвращает уже подключённый клиент, бросает ошибку, если connectTb() не вызывался).
// src/shared/tb.ts (упрощённо)
import { createClient } from 'tigerbeetle-node'
export async function connectTb(address: string): Promise<void> {
const resolved = await resolveAddress(address) // DNS → IP
_client = createClient({ cluster_id: 0n, replica_addresses: [resolved] })
}
export function getTb(): TbClient {
if (!_client) throw new Error('TigerBeetle client not initialized — call connectTb() first')
return _client
}
Особенность: TB-клиент принимает только raw IPv4-адреса в replica_addresses (не DNS-имена). Поэтому при старте PM выполняет DNS-резолв хоста в IP (node:dns/promises.lookup с family: 4). Без этого подключение к TB по hostname tigerbeetle:3000 упадёт с ошибкой парсинга адреса.
cluster_id: 0n — единственный кластер; в multi-cluster схемах сюда подставляется реальный идентификатор.
Клиент закрывается симметрично через closeTb() (вызывается в graceful shutdown PM).
3. Классы аккаунтов¶
Полное описание классов аккаунтов (USER_WALLET, TRANSIT, REVENUE, NOSTRO, MERCHANT_WALLET, MERCHANT_SETTLEMENT, AGENT_WALLET, AGENT_SETTLEMENT, SERVICE_ACCOUNT, EQUITY), их флагов, кодов и формул именования — см. отдельный раздел архитектуры:
→ ../architecture/05-tigerbeetle-accounts.md
Здесь — только короткая сводка, нужная для понимания transfer-кодов и схемы переводов:
| AccountType | Код | debits_must_not_exceed_credits |
Пример имени |
|---|---|---|---|
USER_WALLET |
10 | да | user.42.THB |
TRANSIT |
20 | нет | system.transit.IPPS.THB |
REVENUE |
30 | нет | system.revenue.THB |
NOSTRO |
40 | нет | system.nostro.ipps.THB |
MERCHANT_WALLET |
50 | да | merchant.123.THB |
MERCHANT_SETTLEMENT |
60 | нет | merchant.123.settlement.THB |
SERVICE_ACCOUNT |
70 | да | service.7.THB |
EQUITY |
80 | нет | system.equity.THB |
AGENT_WALLET |
90 | да | agent.55.THB |
AGENT_SETTLEMENT |
100 | нет | agent.55.settlement.THB |
ID аккаунта в TB — uuidv5(name, TB_NS), преобразованный в bigint (128 бит). Это делает идентификаторы полностью детерминированными: зная имя, любой сервис может проверить, что аккаунт в TB соответствует записи в pm.tb_account_map. Подробнее — см. ../architecture/06-deterministic-ids.md.
4. Transfer codes¶
Семантические коды переводов — единый набор из src/ledger/transfer-codes.ts. Код пишется в поле code каждого TB-перевода и используется для отчётности/аналитики (в TB можно фильтровать getAccountTransfers по code).
// src/ledger/transfer-codes.ts
export const TRANSFER_CODES = {
PAYMENT: 10,
APP_FEE: 20,
PSP_FEE: 30,
} as const
| Код | Значение | Что означает |
|---|---|---|
PAYMENT |
10 | Основное движение средств по интенту: списание/зачисление principal-суммы. |
APP_FEE |
20 | Комиссия приложения OneWallet (revenue OneWallet) — отдельный перевод на system.revenue.THB. |
PSP_FEE |
30 | Комиссия внешнего платёжного провайдера — отдельный перевод (обычно списывается из revenue или с пользователя). |
Один интент → один batch из 1–3 linked-переводов с разными кодами. Например, депозит с комиссией провайдера даст два перевода в одном batch: (NOSTRO → USER_WALLET, code=PAYMENT, amount=principal) + (NOSTRO → REVENUE, code=PSP_FEE, amount=fee).
5. Two-phase transfers (pending / post / void)¶
PM использует двухфазную модель TB переводов: сначала создаётся pending-перевод (резервирует средства, не меняет *_posted балансы — только *_pending), затем по результату саги либо post_pending_transfer (закоммитить), либо void_pending_transfer (откатить).
Реализация — src/ledger/transfers.ts:
5.1 authorize(transfers: PendingTransfer[])¶
Создаёт batch из N pending-переводов. Все переводы в batch связаны флагом linked (см. раздел 6).
export async function authorize(transfers: PendingTransfer[]): Promise<string[]> {
const errors = await tb.createTransfers(transfers.map((t, i) => ({
id: t.id,
debit_account_id: t.debitId,
credit_account_id: t.creditId,
amount: t.amount,
pending_id: 0n,
timeout: t.timeout, // секунды до auto-void
ledger: t.ledger,
code: t.code, // PAYMENT / APP_FEE / PSP_FEE
flags: withLinked(TransferFlags.pending, i, total),
timestamp: 0n,
})))
// ...
return transfers.map(t => bigIntToUuid(t.id))
}
Ключевые поля:
- timeout (в секундах) — TB сам автоматически делает void после истечения; используется как защита от «зависших» pending в случае краха PM до post/void.
- flags = TransferFlags.pending [| linked] — pending-режим.
- id — детерминированный, см. src/ledger/id-gen.ts: uuidv5(intentId + ':' + index, TRANSFER_NS). Повторный вызов authorize() с тем же intentId вернёт ошибку exists от TB и не создаст дубль.
5.2 settle(pendingUuids: string[])¶
Подтверждает (постит) ранее созданные pending-переводы. Для каждого pendingId создаётся новый перевод с flags = post_pending_transfer и id = pendingId XOR 1 (детерминированный post-id).
export async function settle(pendingUuids: string[]): Promise<void> {
// post с amount = AMOUNT_MAX (= 2^128-1) → TB постит ровно amount из pending-перевода
// amount=0 в TB >= 0.16 постит ноль (а не «полностью»), поэтому используется AMOUNT_MAX
}
Игнорируемые ошибки: pending_transfer_already_posted — это норма при retry OutboxWorker (см. раздел 8).
5.3 voidAll(pendingUuids: string[])¶
Откатывает pending-переводы. Аналогично settle(), но с flags = void_pending_transfer и id = pendingId XOR 2. После void средства возвращаются на исходные credits_posted/debits_posted балансы (точнее, выходят из *_pending).
Игнорируется pending_transfer_already_voided (на случай, если TB уже сделал auto-void по timeout).
5.4 Жизненный цикл pending → post|void¶
PM (saga step "authorize")
│
├─► TB.createTransfers([{ flags: pending, timeout: 300s }])
│ balances: debit.debits_pending += amount
│ credit.credits_pending += amount
│
│ ... ожидание PSP / внутренней проверки ...
│
├─► OutboxWorker / saga step "settle" → TB.createTransfers([{ flags: post_pending_transfer }])
│ balances: debit.debits_pending -= amount
│ debit.debits_posted += amount
│ credit.credits_pending -= amount
│ credit.credits_posted += amount
│
└─► или OutboxWorker / saga step "void" → TB.createTransfers([{ flags: void_pending_transfer }])
balances: debit.debits_pending -= amount
credit.credits_pending -= amount
(*_posted не меняются)
Если PM крашится между authorize и settle/void — TB автоматически сделает void через timeout секунд (по умолчанию 300 = 5 минут). После рестарта PM OutboxWorker подхватит зависшие intent-ы и повторит operation: TB вернёт pending_transfer_already_voided (если auto-void уже сработал) или применит наш void/post (если успели до timeout).
6. Linked events (atomic batch)¶
Один интент часто требует нескольких переводов, которые должны быть применены атомарно — либо все, либо ни одного. Например, депозит с комиссией провайдера:
NOSTRO → TRANSIT(*), code=PAYMENTTRANSIT(*) → USER_WALLET, code=PAYMENTNOSTRO → REVENUE, code=PSP_FEE
Если хоть один перевод упадёт (например, debits_must_not_exceed_credits), TB откатит весь batch.
Механизм — TransferFlags.linked: устанавливается на всех переводах batch, кроме последнего. Хелпер:
function withLinked(flags: number, index: number, total: number): number {
return index < total - 1 ? flags | TransferFlags.linked : flags
}
Все три функции (authorize, settle, voidAll) используют эту схему — каждая саговая операция передаётся в TB одним createTransfers()-вызовом с правильно расставленными linked-флагами.
6.1 Использование в saga-runner¶
src/intent/saga-runner.ts — это простой последовательный исполнитель шагов канала (Channel):
export async function runSaga(channel: Channel, ctx: IntentContext): Promise<StepOutcome> {
for (const step of channel.steps) {
const outcome = await step(ctx)
if (outcome.done !== 'continue') return outcome
}
throw new Error(`Channel ${channel.name}: steps exhausted without final outcome`)
}
Шаги конкретных каналов (src/intent/channels/*) собирают массив PendingTransfer[] и передают его одним вызовом в authorize() — таким образом весь батч переводов одного интента всегда атомарен на уровне TB. То же самое — на settle/void: один tb.createTransfers([...]) на все pending одного интента.
7. Деталь: детерминированные ID¶
ID аккаунтов и переводов в TB — детерминированные, выводятся из неизменяемых строковых ключей через uuidv5:
- Account ID —
uuidv5(accountName, TB_NS), гдеTB_NS = '3e7b4a1c-9f2d-5e8a-b6c3-4d1f07e2a9b5'(src/ledger/accounts.ts). - Pending transfer ID —
uuidv5(intentId + ':' + index, TRANSFER_NS), гдеTRANSFER_NS = '7a9f3c1e-5b2d-4e8a-9f6c-3d1e07a2b5c4'(src/ledger/id-gen.ts). - Post-transfer ID —
pendingId XOR 1(src/ledger/transfers.ts). - Void-transfer ID —
pendingId XOR 2(src/ledger/transfers.ts).
Следствия:
- Retry любой операции (authorize/settle/void) идемпотентен — TB вернёт exists или already_posted/voided для уже применённых ID.
- ID можно верифицировать без хранения соответствия в БД — зная intentId и индекс, можно пересчитать ID перевода и спросить TB.
- Namespace UUID-ы (TB_NS, TRANSFER_NS) категорически нельзя менять — это сломает все существующие ID и сделает прошлые переводы недоступными.
Подробнее — см. ../architecture/06-deterministic-ids.md.
8. Что НЕ делаем (NO-GO)¶
8.1 Никакой сервис, кроме PM, не пишет в TigerBeetle¶
| Сервис | Доступ к TB | Через что взаимодействует с балансами |
|---|---|---|
| Payment Manager | write + read | напрямую SDK (tigerbeetle-node@0.17.4) |
| Auth Center | нет | POST /intents (write) + GET /api/pm/accounts/balance (read, через nginx-прокси) |
| Admin Panel | read-only | прямой TB SDK, только lookupAccounts, lookupTransfers, getAccountBalances, getAccountTransfers, queryAccounts, queryTransfers |
| mini-app backends | нет | POST /intents через HMAC |
| PSP-адаптеры | нет | Redis Streams → PM, далее PM пишет в TB |
Все write-операции — исключительно через PM (POST /intents с HMAC). Это даёт:
- один-единственный путь применения финансовых правил (rule-engine, лимиты, fee-policy);
- единый аудит-лог в pm.intents и Outbox;
- единая точка для отката/idempotency.
8.2 Admin Panel — read-only¶
Admin Panel имеет собственного TB-клиента (tigerbeetle-node тот же), но использует только read-методы:
- lookupAccounts([ids]) — посмотреть один или несколько аккаунтов;
- lookupTransfers([ids]) — посмотреть переводы;
- getAccountBalances({ account_id }) — историческая выборка балансов;
- getAccountTransfers({ account_id, code, ... }) — историческая выборка переводов;
- queryAccounts(...), queryTransfers(...) — query API.
Запреты для Admin Panel:
- ❌ createAccounts — все системные аккаунты создаёт PM в seedAccounts() при старте;
- ❌ createTransfers — любая корректировка/правка балансов делается только через PM-интенты (например, операционный канал MANUAL_ADJUSTMENT).
Документация по Admin-операциям — см. payment-manager/docs/ADMIN_PANEL.md.
8.3 Прочие запреты¶
- ❌ Не вызывать
tb.createTransfers()без предварительногоgetTb()— он бросит явную ошибку «not initialized». - ❌ Не передавать в
replica_addressesDNS-имена напрямую — TB парсит только IP. PM делает резолв заранее (resolveAddress()вsrc/shared/tb.ts). - ❌ Не повторно использовать
cluster_idв нескольких физических кластерах — PM сейчас работает наcluster_id: 0n. - ❌ Не менять namespace UUID-ы (
TB_NS,TRANSFER_NS) — это безвозвратно ломает все существующие ID. - ❌ Не запускать несколько экземпляров OutboxWorker одновременно — будет двойной post/void.
- ❌ Не переиспользовать индексы (
uuidv5(intentId + ':' + index, ...)) с разной семантикой переводов —indexфиксирован за шагом канала.
9. Ссылки на документацию TigerBeetle¶
Все ссылки актуальны на момент написания; точечные fragment-ы из официального single-page reference:
| Тема | URL |
|---|---|
| Полный single-page reference | https://docs.tigerbeetle.com/single-page |
| Account (структура, флаги) | https://docs.tigerbeetle.com/single-page#reference-account |
| Transfer (структура, флаги) | https://docs.tigerbeetle.com/single-page#reference-transfer |
| Two-phase transfers (coding) | https://docs.tigerbeetle.com/single-page#coding-two-phase-transfers |
| Linked events (coding) | https://docs.tigerbeetle.com/single-page#coding-linked-events |
| Node.js client (clients) | https://docs.tigerbeetle.com/single-page#clients-node-js |
| Recipes (паттерны) | https://docs.tigerbeetle.com/single-page#coding-recipes |
10. Внутренние ссылки¶
- ../architecture/05-tigerbeetle-accounts.md — классы аккаунтов, флаги, формулы именования.
- ../architecture/06-deterministic-ids.md — устройство детерминированных ID для TB-аккаунтов и переводов.
- ../architecture/04-two-phase-channels.md — как двухфазная модель TB используется в каналах PM.
- ../architecture/03-intent-saga.md — как saga-runner вызывает
authorize/settle/voidAll.