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

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.jsondependencies

Версия зафиксирована точно (без ^) — даунгрейд/апгрейд требует ручной проверки совместимости форматов сообщений с кластером.

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)

Один интент часто требует нескольких переводов, которые должны быть применены атомарно — либо все, либо ни одного. Например, депозит с комиссией провайдера:

  1. NOSTRO → TRANSIT(*), code=PAYMENT
  2. TRANSIT(*) → USER_WALLET, code=PAYMENT
  3. NOSTRO → 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 IDuuidv5(accountName, TB_NS), где TB_NS = '3e7b4a1c-9f2d-5e8a-b6c3-4d1f07e2a9b5' (src/ledger/accounts.ts).
  • Pending transfer IDuuidv5(intentId + ':' + index, TRANSFER_NS), где TRANSFER_NS = '7a9f3c1e-5b2d-4e8a-9f6c-3d1e07a2b5c4' (src/ledger/id-gen.ts).
  • Post-transfer IDpendingId XOR 1 (src/ledger/transfers.ts).
  • Void-transfer IDpendingId 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_addresses DNS-имена напрямую — 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. Внутренние ссылки