Модуль channels¶
Реализации платёжных каналов (channel'ов) — концентрируют логику того, как и когда деньги двигаются в TigerBeetle. Каждый channel — это бизнес-сценарий перевода (P2P, IPPS, сервисный, админский, инвойс), сводящийся к набору шагов TB-проводок.
1. Назначение модуля¶
Модуль src/channels/* содержит реализации платёжных каналов — единиц поведения, описывающих как именно деньги перемещаются по счетам TigerBeetle для конкретного бизнес-сценария. Channel:
- получает контекст intent'а из intent-handler;
- выполняет последовательность TB-проводок (создаёт PENDING/POST_PENDING transfers, прямые переводы, либо вообще не трогает TB на стадии reserve);
- возвращает
StepOutcome('continue' | 'authorized' | 'settled'), сигнализируя intent-saga что делать дальше.
Логика комиссий (feeSplits), резолва account ID (fromAccountId, toAccountId, transitId) и публикации событий выполняется снаружи channel'а — в intent/handler.ts и intent/context-builder.ts. Channel получает уже подготовленный IntentContext и сосредоточен исключительно на TigerBeetle.
В системе сейчас зарегистрированы ровно пять channel'ов:
INTERNAL_P2P— синхронный P2P-перевод между внутренними кошельками;IPPS_TRANSFER— outbound payment через PromptPay (PSP IPPS);SERVICE_TRANSFER— сервисные движения (комиссии, начисления, бонусы);ADMIN— операционные правки баланса вручную (без transit);MERCHANT_INVOICE— двухфазный invoice-канал (TwoPhaseChannel).
Дисциплина именования:
ADMIN— это имя канала, аADMIN_TRANSFER— этоoperationType(см.03-operation-types.md). PSP-имена (IPPS,QP,WISE) — это не channel'ы; они хранятся вpsp_tx_map.psp_nameи используются внутриIPPS_TRANSFER.
2. Структура файлов¶
src/channels/
├── _types.ts # типы Channel / SinglePhaseChannel / TwoPhaseChannel / ReserveContext
├── _p2p-saga.ts # переиспользуемая authorize+settle P2P-сага
├── internal-p2p.ts # INTERNAL_P2P channel (использует _p2p-saga)
├── ipps-transfer.ts # IPPS_TRANSFER channel (single pending + psp_tx_map)
├── service-transfer.ts # SERVICE_TRANSFER channel (делегирует в internal-p2p.steps)
├── admin-transfer.ts # ADMIN channel (прямой transfer без transit)
└── merchant-invoice.ts # MERCHANT_INVOICE TwoPhaseChannel (reserve/redeem/cancel/expire)
| Файл | Назначение |
|---|---|
_types.ts |
Дискриминированный union Channel = SinglePhaseChannel | TwoPhaseChannel, контексты для двухфазного канала, тип-guard isTwoPhase(). |
_p2p-saga.ts |
Чистые функции runP2pAuthorize / runP2pSettle / runP2pSaga. Извлечены из internal-p2p.ts ради переиспользования в MERCHANT_INVOICE.redeem(). |
internal-p2p.ts |
Один экспорт internalP2PChannel — двухшаговый SinglePhaseChannel поверх P2P-саги. |
ipps-transfer.ts |
Один экспорт ippsTransferChannel. На step 1 создаёт PENDING transfer и вставляет psp_tx_map(state='NEW'). Step 2 — no-op (settle асинхронно). |
service-transfer.ts |
Лёгкая обёртка: использует internalP2PChannel.steps под именем SERVICE_TRANSFER. |
admin-transfer.ts |
Прямой direct-transfer без transit-счёта, requiresTransit: false. |
merchant-invoice.ts |
Единственный пока TwoPhaseChannel. reserve() создаёт invoice без TB; redeem() запускает runP2pSaga(). |
3. Ключевые типы¶
Полные определения — в _types.ts и intent/types.ts.
Channel¶
Дискриминатор — поле kind. Если kind === 'two-phase' → TwoPhaseChannel; если kind === 'single-phase' | undefined → SinglePhaseChannel. Сужение делает тип-guard:
export function isTwoPhase(c: Channel): c is TwoPhaseChannel {
return (c as TwoPhaseChannel).kind === 'two-phase'
}
isTwoPhase реэкспортируется из intent/step-registry.ts — это единственная точка входа для intent-handler'а.
SinglePhaseChannel¶
Классический канал на массиве steps[]:
interface SinglePhaseChannel extends LegacyChannel {
kind?: 'single-phase'
name: string
tbPendingTimeout: number // секунды; 0 = TB pending не auto-expire
requiresTransit?: boolean // нужен ли transit-счёт
steps: Array<(ctx: IntentContext) => Promise<StepOutcome>>
}
Каждый step принимает IntentContext и возвращает StepOutcome. Intent-saga прогоняет steps по очереди, останавливаясь на первом done !== 'continue'.
TwoPhaseChannel¶
interface TwoPhaseChannel {
kind: 'two-phase'
name: string
tbPendingTimeout: number
reserve(ctx: ReserveContext): Promise<ReserveOutcome>
redeem(ctx: IntentContext): Promise<StepOutcome>
cancel(ctx: CancelContext): Promise<void>
expire(ctx: ExpireContext): Promise<void>
}
Четыре жизненных перехода вместо линейных steps. reserve() создаёт hold в БД без обращения к TigerBeetle; redeem() запускает полноценную P2P-сагу; cancel()/expire() — atomic UPDATE статуса с публикацией в Redis.
StepOutcome¶
См. intent/types.ts. Дискриминатор done:
| Значение | Семантика |
|---|---|
'continue' |
Текущий шаг завершён, можно идти дальше; либо завис в async-режиме (как IPPS step 2). |
'authorized' |
Платёж резервирован в TB, ждём подтверждения от внешнего PSP. |
'settled' |
Платёж окончательно проведён; intent → SETTLED. |
ReserveContext / ReserveOutcome / CancelContext / ExpireContext¶
Контексты для TwoPhaseChannel:
interface ReserveContext { intent: Intent; ttlSeconds: number; appScope: 'w' | 'c' | 'a' }
interface ReserveOutcome { expiresAt: Date; qrSignature: Buffer }
interface CancelContext { intent: Intent; reason: string; actorUserId: number }
interface ExpireContext { intent: Intent }
appScope различает источник запроса (wallet / checkout / admin) и влияет на QR-подпись.
4. Реализации channel'ов¶
4.1 INTERNAL_P2P — синхронный P2P¶
Файл: internal-p2p.ts. Двухшаговый SinglePhaseChannel:
export const internalP2PChannel: SinglePhaseChannel = {
kind: 'single-phase',
name: 'INTERNAL_P2P',
tbPendingTimeout: 300,
requiresTransit: true,
steps: [
async (ctx) => { await runP2pAuthorize(ctx); return { done: 'continue' } },
async (ctx) => runP2pSettle(ctx),
],
}
Step 1 (authorize) создаёт PENDING batch в TB (T0: user→transit, T1: transit→recip, T2+: transit→PRE fee accounts). Step 2 (settle) делает POST_PENDING + прямые transfer'ы для POST-комиссий. Возвращает { done: 'settled' } — intent сразу SETTLED.
tbPendingTimeout: 300 — pending-транзакции истекают через 5 минут (короткое окно для синхронного flow).
4.2 IPPS_TRANSFER — outbound PromptPay через PSP¶
Файл: ipps-transfer.ts. Два step'а, но реальную работу делает только первый:
- Step 1 (authorize) — создаёт один TB pending transfer (user →
system.nostro.ipps.{currency}) иINSERT INTO pm.psp_tx_map (intent_id, psp_name='IPPS', state='NEW'). Эту запись подхватываетPspWorkerи драйвит state-machine query+confirm. - Step 2 (NOOP) — возвращает
{ done: 'continue' }. Handler видитcontinueпосле последнего шага и попадает в ветку AUTHORIZED — отвечает201 + requiresMonitoring=true. Settlement происходит асинхронно:PspWorker → applyOutcome → outbox → OutboxWorker → TB.post_pending(илиvoid_pending) →tx_history.
Критичная деталь — tbPendingTimeout: 0. TB pending для IPPS никогда не expire'ит автоматически: только OutboxWorker (по итогу PspWorker) может его settle/void. Фундаментальное отличие от INTERNAL_P2P, где pending живёт 5 минут.
PSP-имя 'IPPS' хардкожено в файле — мульти-PSP роутинг (QP/Wise) появится в channel-resolver'е, когда подключим эти драйверы. См. dev/modules/psp.md (будущий доку).
4.3 SERVICE_TRANSFER — сервисные переводы¶
Файл: service-transfer.ts. Реализация — буквально 5 строк:
export const serviceTransferChannel: Channel = {
name: 'SERVICE_TRANSFER',
tbPendingTimeout: 0,
steps: internalP2PChannel.steps,
}
Канал используется для внутренних движений между системными счетами (например выплата комиссии партнёру, начисление бонуса, ручная компенсация через operator UI). По механике идентичен INTERNAL_P2P (та же P2P-сага), но логически отделён ради:
- разделения политик
auth-policies(см.dev/reference/database/08-auth-policies.md); - маршрутизации в
pm.payment_routeпоoperationType; - читаемости в логах и
intent_event.
tbPendingTimeout: 0 — pending не задействован в практике (саге settle'ит синхронно), но значение оставлено явно для согласованности.
4.4 ADMIN — операционная правка баланса¶
Файл: admin-transfer.ts. Канал называется ровно ADMIN (без суффикса _TRANSFER — это имя operationType, не channel'а):
export const adminTransferChannel: Channel = {
name: 'ADMIN',
tbPendingTimeout: 0,
requiresTransit: false, // прямой from→to, transit не нужен
steps: [ ... ],
}
Step 1 создаёт прямой (не pending) transfer без transit-аккаунта: from → to, amount, flags: 0, code: PAYMENT. Возвращает { done: 'authorized', tbTransferIds }. Step 2 — формальный { done: 'settled' } (TB-проводка уже совершена).
Используется админ-panel'ью для:
- ручных коррекций (например исправить просроченный отказ платежа);
- начисления компенсаций / бонусных балансов;
- технических переводов между sub-аккаунтами.
Защита от злоупотреблений — на уровне auth-policies + HMAC + audit log (operationType = ADMIN_TRANSFER всегда оставляет след в intent_event и tx_history).
4.5 MERCHANT_INVOICE — TwoPhaseChannel¶
Файл: merchant-invoice.ts. Единственный на сегодня TwoPhaseChannel:
| Метод | Что делает |
|---|---|
reserve() |
INSERT/UPDATE инвойса без TigerBeetle. Считает expiresAt, подписывает QR-payload через signQrPayload, сохраняет qr_signature и reserved_at. Возвращает { expiresAt, qrSignature }. |
redeem() |
Покупатель отсканировал QR и подтвердил оплату → запускаем runP2pSaga(ctx) (authorize+settle в TB одним вызовом). Возвращает StepOutcome от саги ('settled'). |
cancel() |
Atomic UPDATE intent SET status='CANCELED' WHERE id=? AND status='CREATED'. Optimistic concurrency: если 0 строк затронуто → ConflictError('CANNOT_CANCEL', ...). Публикует CANCELED в Redis. |
expire() |
Atomic UPDATE intent SET status='EXPIRED' WHERE id=? AND status='CREATED' AND expires_at < now(). Вызывается invoice-expiry-sweeper'ом (см. dev/modules/workers.md). No-op если инвойс уже завершён. |
Подробнее про двухфазный жизненный цикл — в architecture/04-two-phase-channels.md.
4.6 _p2p-saga.ts — переиспользуемая P2P-сага¶
Файл: _p2p-saga.ts. Три экспортируемые функции:
runP2pAuthorize(ctx)— создаёт PENDING batch в TigerBeetle: T0 (user → transit, total = amount + preFee, PENDING|LINKED), T1 (transit → recip, PENDING [|LINKED]), T2+ (transit → preFeeAccount, PENDING). Мутируетctx.tbTransferIds.runP2pSettle(ctx)— POST_PENDING всех pending + прямые transfer'ы для POST-комиссий. Partial-post на T1 (amount = net - postTotalFee), затемtransit → postFeeAccountиз освобождённого баланса. Обрабатываетpending_transfer_expired→PendingExpiredError. Возвращает{ done: 'settled', tbTransferIds: [...] }.runP2pSaga(ctx)— single-pass:await runP2pAuthorize(ctx); return runP2pSettle(ctx).
Зачем выделено. Изначально весь код жил в internal-p2p.ts. При появлении MERCHANT_INVOICE.redeem() потребовалось вызвать ту же логику (authorize+settle одним заходом) — выделили в отдельный модуль. Сейчас _p2p-saga.ts используется:
- в
internalP2PChannel.steps(черезrunP2pAuthorize+runP2pSettleпо отдельности, ради корректногоdone: 'continue' | 'settled'); - в
merchantInvoiceChannel.redeem()(черезrunP2pSagasingle-pass); - транзитивно — в
serviceTransferChannel(он переиспользуетinternalP2PChannel.steps).
Инвариант: любые правки P2P-механики (порядок проводок, флаги PENDING/LINKED, обработка комиссий) делаем только в _p2p-saga.ts. Channel'ы — тонкие обёртки.
5. Жизненный цикл channel'а¶
- Для SinglePhaseChannel (
INTERNAL_P2P,IPPS_TRANSFER,SERVICE_TRANSFER,ADMIN) — линейный прогонsteps[]через intent-saga. См.architecture/03-intent-saga.md. - Для TwoPhaseChannel (
MERCHANT_INVOICE) — четыре раздельных хода (reserve→redeem|cancel|expire). См.architecture/04-two-phase-channels.mdиreference/passport/02-channels.md.
Регистрируются channel'ы при старте сервера в server.ts через registerChannel():
import { registerChannel } from './intent/step-registry.js'
import { internalP2PChannel } from './channels/internal-p2p.js'
import { ippsTransferChannel } from './channels/ipps-transfer.js'
import { serviceTransferChannel } from './channels/service-transfer.js'
import { adminTransferChannel } from './channels/admin-transfer.js'
import { merchantInvoiceChannel } from './channels/merchant-invoice.js'
registerChannel(internalP2PChannel)
registerChannel(ippsTransferChannel)
registerChannel(serviceTransferChannel)
registerChannel(adminTransferChannel)
registerChannel(merchantInvoiceChannel)
Intent-handler получает имя channel'а из pm.payment_route (по operationType) и достаёт реализацию через getChannel(name). Тип-guard isTwoPhase() решает, идти ли по steps[] или вызвать reserve().
6. Конфигурация¶
pm.payment_route — mapping operationType → channel¶
Таблица сопоставляет operationType (значение из запроса клиента — P2P_TRANSFER, IPPS_OUT, MERCHANT_PAY, ADMIN_TRANSFER и т.п.) с именем channel'а. Подробно — в dev/reference/database/04-payment-route.md.
| operationType | channel | примечание |
|---|---|---|
P2P_TRANSFER |
INTERNAL_P2P |
синхронный wallet→wallet |
IPPS_OUT |
IPPS_TRANSFER |
outbound PromptPay |
SERVICE_FEE |
SERVICE_TRANSFER |
внутренние комиссии |
ADMIN_TRANSFER |
ADMIN |
ручная правка баланса |
MERCHANT_PAY |
MERCHANT_INVOICE |
двухфазный invoice |
См. полный перечень operationType в reference/passport/03-operation-types.md.
pm.fee_rule — правила комиссий¶
Channel не знает про комиссии напрямую. feeSplits рассчитывается в rule-engine (dev/modules/rule-engine.md) до запуска channel'а и кладётся в IntentContext. P2P-сага читает оттуда timing: 'PRE' | 'POST' и сама строит соответствующие T2+ transfer'ы.
tbPendingTimeout¶
Зашит в коде каждого channel'а, не в БД. Значения:
| Channel | timeout | смысл |
|---|---|---|
INTERNAL_P2P |
300 | 5 минут — синхронный flow укладывается с запасом |
IPPS_TRANSFER |
0 | внешний PSP, никогда не auto-expire |
SERVICE_TRANSFER |
0 | pending не используется на практике |
ADMIN |
0 | прямой transfer без pending |
MERCHANT_INVOICE |
0 | reserve() не задействует TB |
7. Тестирование¶
В test/channels/ лежат вертикальные тесты на каждый смысловой channel:
internal-p2p.test.ts— покрывает классический P2P + комиссии PRE/POST. Параллельно служит тестом для_p2p-saga.ts.ipps-transfer.test.ts— TB pending +psp_tx_mapinsertion + step 2 no-op + reaction handler'а наcontinue.merchant-invoice.test.ts— четыре сценария TwoPhaseChannel:reserve,redeem(через runP2pSaga),cancel(включаяCANNOT_CANCEL),expire.
Отдельных файлов для service-transfer и admin-transfer нет — для SERVICE_TRANSFER логика поведения покрывается internal-p2p.test.ts (он переиспользует те же steps), для ADMIN — интеграционными тестами в test/intent/.
Запуск:
Подробнее про testing-стандарты — docs/dev/testing/overview.md и dev/testing/.
8. Связанные модули¶
dev/modules/intent.md— intent-saga, handler, context-builder; запускает channel'ы.dev/modules/ledger.md—id-gen,transfer-codes, helper'ы для TigerBeetle; channel'ы вызывают их напрямую.dev/modules/rule-engine.md— расчётfeeSplits; результат прокидывается вIntentContextдо вызова channel'а.dev/modules/psp.md— следующий уровень дляIPPS_TRANSFER: выбор PSP по operationType + currency + amount (Phase 2B).dev/modules/workers.md—OutboxWorker,PspWorker,invoice-expiry-sweeper; завершают асинхронную частьIPPS_TRANSFERи обслуживаютMERCHANT_INVOICE.expire().dev/architecture/03-intent-saga.md— как saga прогоняетsteps[].dev/architecture/04-two-phase-channels.md— жизненный цикл двухфазного канала.dev/reference/passport/02-channels.md— канонический контрактChannelдля всех клиентов.
9. Заготовки на будущее¶
В коде сейчас нет stub-channel'ов для будущих integration'ов — все 5 зарегистрированных channel'ов работающие. Однако из комментариев и архитектуры явно следует план расширения:
IPPS_TRANSFER→ мульти-PSP. Вipps-transfer.ts:67-71psp_name='IPPS'хардкожен. План: добавить channel-resolver, который выбираетpsp_nameна лету (QP,WISE,IPPS) на основании operationType + currency + amount. Channel останется один, под капотом — переключение драйверов.MERCHANT_INVOICE→ расширение. Сейчасredeem()вызывает толькоrunP2pSaga(плательщик платит из внутреннего кошелька). На горизонте — invoice'ы, оплачиваемые внешним PSP (QR-приём через IPPS-инпут или Wise). Это потребует либо нового TwoPhaseChannel (EXTERNAL_INVOICE), либо ветвления внутриredeem()поchannelатрибута intent'а.- Новые TwoPhaseChannel'ы: subscription holds, escrow, instalment plans. Шаблон уже есть (
merchant-invoice.ts); добавление нового канала — это новый файлsrc/channels/*.ts+ регистрация вserver.ts+ запись вpm.payment_route. SERVICE_TRANSFER→ split-disbursement. Сейчас он буквально P2P. План — разрешить ему батчевые переводы (одна транзакция → N reciepents), что потребует ослабления моделиtoAccountId(или массив получателей вIntentContext.metadata).
Все будущие channel'ы должны:
- реализовывать
Channelиз_types.ts; - регистрироваться через
registerChannel()вserver.ts; - иметь запись в
pm.payment_routeдля своихoperationType; - иметь тест в
test/channels/; - документироваться отдельным разделом здесь либо подмодулем в
dev/modules/.