Модуль ledger¶
Единственное место в PM, откуда происходит запись в TigerBeetle. Содержит детерминированную генерацию account/transfer ID, helpers для двухфазных проводок (authorize/settle/voidAll), seed системных аккаунтов и HTTP endpoint'ы для создания кошельков и чтения баланса/истории.
1. Назначение модуля¶
Модуль src/ledger/* — это единственный write-канал к TigerBeetle во всём Payment Manager. Все остальные модули (channels, intent, sagas) делают TB-проводки исключительно через функции этого модуля. Конкретно ledger отвечает за:
- Account lifecycle — создание системных и пользовательских аккаунтов в TB; поддержка
pm.tb_account_map(мост между человекочитаемым именем и UUID TB-аккаунта). - Детерминированную генерацию ID — account ID =
uuidv5(name, ACCOUNT_NS), transfer ID =uuidv5(intentId + ':' + index, TRANSFER_NS). Это гарантирует идемпотентность ретраев и верифицируемость без отдельного state. - TB transfer helpers — функции
authorize()/settle()/voidAll()обёрнуты вокруг сырогоtb.createTransfers()с правильной логикой LINKED-флагов, post/void ID и игнорирования "already posted/voided" ошибок. - HTTP endpoints —
/accounts*для admin/auth-center: создать кошелёк, узнать баланс, получить историю транзакций (читается изpm.tx_history, баланс — напрямую из TB). - TB client lifecycle — обёртка над
tigerbeetle-node(модульshared/tb.ts): подключение, DNS-резолв адреса, переиспользуемый singleton-клиент. - Seed/verify системных аккаунтов на старте PM (
seedAccounts+verifySystemAccounts).
Write в TigerBeetle разрешён только из
src/ledger/transfers.ts(черезauthorize/settle/voidAll) и черезsrc/ledger/accounts.ts::createAccount(). Каналы (src/channels/*) вызываютtransfers.ts(часто через переиспользуемую сагу_p2p-saga.ts). Чтение (lookupAccounts,getAccountTransfersи т.п.) — допустимо отовсюду черезgetTb().
2. Структура файлов¶
src/ledger/
├── accounts.ts # accountName(), accountId(), createAccount(), seedAccounts(), getAccountByName/ByTbId
├── id-gen.ts # uuidv5TbTransfer(), tbIdFromUuid() — генерация transfer-ID
├── routes.ts # POST /accounts, GET /accounts/:name/balance, GET /accounts/:name/transactions
├── transfer-codes.ts # TRANSFER_CODES — семантические коды для TB.code (PAYMENT/APP_FEE/PSP_FEE)
└── transfers.ts # authorize() / settle() / voidAll() — низкоуровневые TB write-операции
src/shared/
└── tb.ts # getTb() / connectTb() / closeTb() — singleton-клиент tigerbeetle-node
| Файл | Назначение |
|---|---|
accounts.ts |
Генерация имён аккаунтов (user.123.THB, system.transit.IPPS.THB и т.п.), accountId() (UUIDv5), createAccount(), seedAccounts(), verifySystemAccounts(), lookup-функции getAccountByName / getAccountByTbId. |
id-gen.ts |
Детерминированный transfer ID: uuidv5TbTransfer(intentId, index) и обратная конверсия tbIdFromUuid(uuid) → bigint. |
routes.ts |
HTTP plugin для Fastify: POST /accounts, GET /accounts/:name/balance, GET /accounts/:name/transactions. Все endpoint'ы под HMAC. |
transfer-codes.ts |
Константы TRANSFER_CODES = { PAYMENT: 10, APP_FEE: 20, PSP_FEE: 30 } — семантика поля Transfer.code (например code=10 маркирует основной перевод, 20/30 — комиссии). Используются channel'ами при формировании batch'а transfer'ов. |
transfers.ts |
authorize() создаёт PENDING transfers с LINKED-цепочкой и таймаутом; settle() постит pending по UUID; voidAll() отменяет. Все три функции — единственный write-API к TB. |
shared/tb.ts |
Инициализация и хранение singleton'а tigerbeetle-node клиента. Резолвит hostname → IP (TB не принимает hostnames). |
3. Ключевые типы¶
Account naming convention¶
Все имена аккаунтов детерминированно строятся функцией accountName(type, opts) — это единственное место в системе, где они генерируются:
| Тип | Шаблон имени | Пример |
|---|---|---|
USER_WALLET |
user.{userId}.{currency} |
user.42.THB |
MERCHANT_WALLET |
merchant.{userId}.{currency} |
merchant.1001.THB |
MERCHANT_SETTLEMENT |
merchant.{userId}.settlement.{currency} |
merchant.1001.settlement.THB |
AGENT_WALLET |
agent.{userId}.{currency} |
agent.500.THB |
AGENT_SETTLEMENT |
agent.{userId}.settlement.{currency} |
agent.500.settlement.THB |
SERVICE_ACCOUNT |
service.{userId}.{currency} |
service.7.THB |
TRANSIT |
system.transit.{channel}.{currency} |
system.transit.IPPS.THB |
REVENUE |
system.revenue.{currency} |
system.revenue.THB |
NOSTRO |
system.nostro.{provider}.{currency} |
system.nostro.ipps.THB |
EQUITY |
system.equity.{currency} |
system.equity.THB |
Имя — это источник истины для
accountId(). Любая опечатка или нестабильность в построении имени даст другой UUID и фактически "потеряет" аккаунт. Поэтому строить имена руками запрещено — только черезaccountName().
TigerBeetle Account¶
Аккаунт в TB — это 128-битная запись с накапливаемыми debits_posted/credits_posted/debits_pending/credits_pending. PM использует:
id: u128— детерминированный UUIDv5 от account name.ledger: 1— единственный ledger пока (multi-currency через несколько ledger'ов запланирован, см. §10).code: u16— тип аккаунта (USER_WALLET=10,TRANSIT=20,REVENUE=30,NOSTRO=40,MERCHANT_WALLET=50,MERCHANT_SETTLEMENT=60,SERVICE_ACCOUNT=70,EQUITY=80,AGENT_WALLET=90,AGENT_SETTLEMENT=100).flags— комбинация:debits_must_not_exceed_credits— для всех кошельков и сервисных аккаунтов (не уйти в минус);history— для всех аккаунтов где нужна история (всё кромеTRANSIT).user_data_128—userIdдля wallet-аккаунтов (быстрый back-reference).user_data_64— UNIX timestamp создания (для аудита).
TigerBeetle Transfer¶
interface PendingTransfer {
id: bigint // uuidv5(intentId + ':' + index, TRANSFER_NS) → BigInt
debitId: bigint // accountId(fromName)
creditId: bigint // accountId(toName) — обычно transit
amount: bigint // в satang (THB ×100)
ledger: number // = 1
code: number // TRANSFER_CODES.PAYMENT|APP_FEE|PSP_FEE
timeout: number // секунды; 300 = 5-минутный auto-void
}
code(transfer) иcode(account) — разные перечисления. Accountcodeопределяет тип счёта, transfercode— семантический смысл проводки.
Post/Void ID — детерминированный XOR¶
Чтобы settle/void был верифицируем и идемпотентен, ID derived из pending ID:
function postId(pendingId: bigint): bigint { return pendingId ^ 1n }
function voidId(pendingId: bigint): bigint { return pendingId ^ 2n }
Это снимает необходимость хранить post/void UUID отдельно — они вычислимы из pending UUID. Inverse-операции (post и void) разнесены на разные младшие биты (^1 vs ^2), что исключает коллизию: post-ID никогда не совпадёт с void-ID того же pending'а.
LINKED-цепочка¶
authorize() и settle() навешивают флаг TransferFlags.linked на все transfer'ы кроме последнего. Это превращает массив в atomic batch: если хоть один transfer в цепочке упадёт — TB откатит всю цепочку целиком. Используется например в IPPS-канале, где fee-перевод и основной transfer должны успеть или провалиться атомарно.
AMOUNT_MAX для settle¶
В TigerBeetle ≥ 0.16 amount=0 в post-pending транзакции буквально посттит ноль. Чтобы запостить полную сумму pending, нужно использовать sentinel-значение AMOUNT_MAX = 2^128 - 1. Helper settle() всегда передаёт AMOUNT_MAX — частичная settle не поддерживается на уровне ledger-модуля (если понадобится — это будет отдельный helper settlePartial(uuid, amount)).
4. Основные функции¶
accounts.ts¶
| Функция | Назначение |
|---|---|
accountName(type, opts) |
Строит каноническое имя по типу и userId/channel/currency. Например: accountName('USER_WALLET', {userId: 42, currency: 'THB'}) → 'user.42.THB'. |
accountId(name): bigint |
uuidv5(name, ACCOUNT_NS) → BigInt. Полностью детерминированно. См. ../architecture/06-deterministic-ids.md. |
accountIdToUuid(id) |
Обратное преобразование bigint → строковый UUID для логов/БД. |
createAccount(type, opts, db) |
Идемпотентно создаёт TB-аккаунт + строку в pm.tb_account_map. Возвращает {accountName, tbAccountId, created}. |
seedAccounts(db) |
На старте PM создаёт все системные аккаунты (TRANSIT.*, REVENUE, NOSTRO.ipps, NOSTRO.bank, EQUITY). Идемпотентно. |
verifySystemAccounts(db) |
Проверяет что каждый системный аккаунт существует в TB и в pm.tb_account_map. Только логирует ошибки — не падает (для self-healing). |
getAccountByName(name, db) |
Lookup по имени из pm.tb_account_map. Возвращает {tbAccountId, tbLedger, accountType, userId} либо null. |
getAccountByTbId(uuid, db) |
Обратный lookup — по TB UUID найти имя и userId. Используется webhook'ами и админ-панелью. |
transfers.ts¶
| Функция | Назначение |
|---|---|
authorize(transfers[]) |
Создаёт массив PENDING transfers за один tb.createTransfers(). Применяет LINKED-флаг ко всем кроме последнего (atomic batch). Возвращает массив pending-UUID. |
settle(pendingUuids[]) |
Пост pending transfers с amount=AMOUNT_MAX (post full pending amount). LINKED-цепочка. Игнорирует pending_transfer_already_posted (idempotent retry). |
voidAll(pendingUuids[]) |
Отменяет pending transfers. Игнорирует pending_transfer_already_voided. |
id-gen.ts¶
| Функция | Назначение |
|---|---|
uuidv5TbTransfer(intentId, index) |
Строит детерминированный transfer UUID. Каждый transfer в intent получает уникальный index (0,1,2…); ретрай intent'а воспроизведёт тот же UUID — TB вернёт "exists" и не задвоит проводку. |
tbIdFromUuid(uuid) |
UUID-строка → BigInt (для lookupAccounts/lookupTransfers). |
shared/tb.ts¶
| Функция | Назначение |
|---|---|
connectTb(address) |
Резолвит address (host:port или ip:port) в IP через DNS — tigerbeetle-node принимает только IP. Создаёт клиент с cluster_id: 0n. |
getTb() |
Возвращает singleton клиент. Падает если не было connectTb(). |
closeTb() |
Закрывает клиент (graceful shutdown). |
5. Жизненный цикл / HTTP endpoints¶
Все endpoint'ы под HMAC (X-Service-Id, X-Timestamp, X-Signature). См. ../api/auth.md.
POST /accounts — создать кошелёк¶
Создаёт TigerBeetle-аккаунт(ы) для user/merchant/agent.
- Body:
{ userId: number, currency: 'THB', entityType?: 'user'|'merchant'|'agent' } - Header:
X-User-Id(должен совпадать сbody.userId). - ACL:
entityType: 'user'— толькоauth-centerилиadmin-panel. СоздаётUSER_WALLET. ВозвращаетAccountResponse.entityType: 'merchant'|'agent'— толькоadmin-toolилиadmin-panel. Создаёт два аккаунта параллельно: primary wallet + settlement. ВозвращаетDualAccountResponseсprimaryиsettlement.- Статусы:
201— хотя бы один аккаунт был создан.200— все аккаунты уже существовали (идемпотентность).403— caller не имеет права создавать данный entityType.
GET /accounts/:name/balance¶
Возвращает текущий баланс прямо из TB (без БД-кеша).
- Path:
:name— либо имя (user.123.THB), либо TB UUID. Распознаётся по regex. - Response:
{ accountName, balance: string, currency }.balance = credits_posted - debits_posted(строка — bigint). - 404 если аккаунт не найден в TB.
GET /accounts/:name/transactions¶
История транзакций из pm.tx_history (заполняется по completion intent'а).
- Path:
:name— имя или TB UUID. Если UUID — сначала резолв черезpm.tb_account_mapв имя. - Query:
limit(1–100, default 20),offset(≥0, default 0). - Response: массив
{ id, intentId, operationType, direction, amount, feeAmount, currency, createdAt }. СортировкаcreatedAt DESC.
Endpoint
GET /accounts/:name/transactionsчитает из таблицы — не из TB напрямую. Это сделано чтобы вернутьintentId/operationType/direction(TB их не хранит). Соответствие баланс TB ↔ сумма проводок вpm.tx_history— отдельный инвариант, проверяемый reconcile-задачами.
6. Конфигурация¶
Env-переменные¶
| Переменная | Назначение | По умолчанию |
|---|---|---|
TB_ADDRESS |
Адрес TigerBeetle cluster (host:port или ip:port). Hostname резолвится в IP автоматически. |
— обязательная |
Клиент создаётся при старте сервера через connectTb(env.TB_ADDRESS) (см. src/server.ts) и закрывается в graceful shutdown.
Таблица pm.tb_account_map¶
Мост между human-readable name и TB UUID. Колонки:
| Колонка | Назначение |
|---|---|
account_name |
PK, например user.42.THB. |
tb_account_id |
TB UUID (строка). |
tb_ledger |
1 (заготовка под multi-currency). |
account_type |
USER_WALLET / TRANSIT / REVENUE / NOSTRO / MERCHANT_WALLET / MERCHANT_SETTLEMENT / AGENT_WALLET / AGENT_SETTLEMENT / SERVICE_ACCOUNT / EQUITY. |
user_id |
Для wallet-аккаунтов — Serverpod userId; для системных — NULL. Используется для определения direction (DEBIT/CREDIT) в tx_history (см. memory feedback-limit-direction). |
currency |
ISO 4217 (THB). |
Запись в
pm.tb_account_mapделается только черезcreateAccount()— никто другой не пишет туда напрямую (естьonConflictDoNothing()для идемпотентности).
Namespace-UUID¶
Два фиксированных namespace для UUIDv5 — изменение любого из них инвалидирует все существующие ID:
TB_NS(вaccounts.ts) — дляaccountId(name).TRANSFER_NS(вid-gen.ts) — дляuuidv5TbTransfer(intentId, index).
7. Тестирование¶
test/ledger/
├── accounts.test.ts # accountName / accountId / createAccount / seed / lookup
├── id-gen.test.ts # детерминированность uuidv5TbTransfer, tbIdFromUuid roundtrip
├── routes.test.ts # POST /accounts (ACL по entityType), GET /:name/balance, GET /:name/transactions
└── transfers.test.ts # authorize/settle/voidAll, LINKED-флаги, идемпотентность post/void
Все тесты используют реальный TigerBeetle (single-replica в Docker) и реальный PostgreSQL — это integration-уровень, не unit. Setup в test/_setup/. См. testing/examples.md.
Ключевые сценарии:
- Идемпотентность создания аккаунта — повторный
createAccount()должен вернутьcreated: falseи не задвоить строку вtb_account_map. - LINKED-batch atomicity — если один из transfer'ов в массиве
authorize()падает, все откатываются (linked chain). - Post после void — должен возвращать
pending_transfer_already_voided(игнорируется в helper'е). - ACL на
POST /accounts—service-id: payment-managerне должен создавать аккаунты (403).
8. Связанные модули¶
| Модуль | Связь |
|---|---|
intent.md |
intent/context-builder.ts вызывает getAccountByName() для резолва TB-ID; intent-saga вызывает transfers.ts::authorize/settle/voidAll. |
channels.md |
Каналы (internal-p2p, ipps-transfer, service-transfer, admin-transfer, merchant-invoice) — основные потребители transfers.ts. P2P-сага _p2p-saga.ts — тонкая обёртка над authorize + settle. |
../architecture/05-tigerbeetle-accounts.md |
Принципы accounting в TB, типы аккаунтов, инвариант transit.balance = 0, выбор code и flags. |
../architecture/06-deterministic-ids.md |
Зачем UUIDv5, выбор namespace-ов, формулы post/void ID, идемпотентность ретраев. |
memory feedback-limit-direction |
Почему direction (DEBIT/CREDIT) считается через tb_account_map.userId, а не через парсинг имени. |
9. Запрещённые шаблоны¶
- Прямой вызов
getTb().createAccounts()/createTransfers()извнеledger/*— все write-операции только черезaccounts.ts::createAccount()иtransfers.ts::authorize/settle/voidAll. - Ручная запись в
pm.tb_account_map— только черезcreateAccount(). - Изменение namespace-UUID (
TB_NSвaccounts.ts,TRANSFER_NSвid-gen.ts) — инвалидирует все существующие ID. Если когда-нибудь понадобится миграция — это будет отдельный major-релиз с реcid аккаунтов. - Сохранение post/void UUID в БД — они вычисляются как
pending ^ 1n/pending ^ 2n, хранить отдельно избыточно.
10. Заготовки на будущее¶
- Multi-currency через TB ledger — поле
tb_ledgerвpm.tb_account_mapуже есть, сейчас всегда1. Под каждую валюту планируется отдельный TB ledger (THB=1,USD=2, …). Это исключит cross-currency проводки на уровне TB (по дизайну TB не разрешает transfer между разными ledger'ами). FX-операции в этой модели — это два intent'а:OUT_THB+IN_USDс проводкой через FX-pool аккаунт (типNOSTROс провайдеромfx). - NOSTRO для других провайдеров — schema уже умеет
system.nostro.<provider>.<currency>(см.accountName('NOSTRO', { provider, currency })). Сейчас seed создаётippsиbank; новые провайдеры (Wise, Stripe и т.п.) добавляются вSYSTEM_ACCOUNTS. - AGENT-аккаунты в
POST /accounts— endpoint уже поддерживаетentityType: 'agent', но agent-каналы (комиссии агентам) ещё не реализованы — см. TigerBeetle accounts (multi-entity). - Health-check для TB — сейчас
verifySystemAccounts()только логирует ошибки. План — экспортировать как/healthz/tbendpoint и завязать readiness probe Kubernetes. transfer-codes.ts— пока всего три кода (PAYMENT/APP_FEE/PSP_FEE); при появлении новых сценариев (interest accrual, refund, chargeback) сюда добавятся новые семантические коды без изменения схемы TB.