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

Модуль 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_128userId для 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) — разные перечисления. Account code определяет тип счёта, transfer code — семантический смысл проводки.

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_NSaccounts.ts) — для accountId(name).
  • TRANSFER_NSid-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 /accountsservice-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/tb endpoint и завязать readiness probe Kubernetes.
  • transfer-codes.ts — пока всего три кода (PAYMENT/APP_FEE/PSP_FEE); при появлении новых сценариев (interest accrual, refund, chargeback) сюда добавятся новые семантические коды без изменения схемы TB.