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

Модуль 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'ов:

  1. INTERNAL_P2P — синхронный P2P-перевод между внутренними кошельками;
  2. IPPS_TRANSFER — outbound payment через PromptPay (PSP IPPS);
  3. SERVICE_TRANSFER — сервисные движения (комиссии, начисления, бонусы);
  4. ADMIN — операционные правки баланса вручную (без transit);
  5. 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

export type Channel = SinglePhaseChannel | TwoPhaseChannel

Дискриминатор — поле 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_expiredPendingExpiredError. Возвращает { done: 'settled', tbTransferIds: [...] }.
  • runP2pSaga(ctx) — single-pass: await runP2pAuthorize(ctx); return runP2pSettle(ctx).

Зачем выделено. Изначально весь код жил в internal-p2p.ts. При появлении MERCHANT_INVOICE.redeem() потребовалось вызвать ту же логику (authorize+settle одним заходом) — выделили в отдельный модуль. Сейчас _p2p-saga.ts используется:

  1. в internalP2PChannel.steps (через runP2pAuthorize + runP2pSettle по отдельности, ради корректного done: 'continue' | 'settled');
  2. в merchantInvoiceChannel.redeem() (через runP2pSaga single-pass);
  3. транзитивно — в serviceTransferChannel (он переиспользует internalP2PChannel.steps).

Инвариант: любые правки P2P-механики (порядок проводок, флаги PENDING/LINKED, обработка комиссий) делаем только в _p2p-saga.ts. Channel'ы — тонкие обёртки.

5. Жизненный цикл channel'а

Регистрируются 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:

test/channels/
├── internal-p2p.test.ts
├── ipps-transfer.test.ts
└── merchant-invoice.test.ts
  • internal-p2p.test.ts — покрывает классический P2P + комиссии PRE/POST. Параллельно служит тестом для _p2p-saga.ts.
  • ipps-transfer.test.ts — TB pending + psp_tx_map insertion + 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/.

Запуск:

npm test -- test/channels

Подробнее про testing-стандарты — docs/dev/testing/overview.md и dev/testing/.

8. Связанные модули

9. Заготовки на будущее

В коде сейчас нет stub-channel'ов для будущих integration'ов — все 5 зарегистрированных channel'ов работающие. Однако из комментариев и архитектуры явно следует план расширения:

  • IPPS_TRANSFER → мульти-PSP. В ipps-transfer.ts:67-71 psp_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'ы должны:

  1. реализовывать Channel из _types.ts;
  2. регистрироваться через registerChannel() в server.ts;
  3. иметь запись в pm.payment_route для своих operationType;
  4. иметь тест в test/channels/;
  5. документироваться отдельным разделом здесь либо подмодулем в dev/modules/.