Passport · Channels¶
Реестр платёжных каналов Payment Manager: код, фаза, файл реализации и логика settle. Канал — это стратегия исполнения платежа в TigerBeetle (и/или внешних рельсах), которая роутится из operationType после прохождения rule-engine.
Назначение¶
Channel — это финальная стратегия исполнения intent-а. После того как rule-engine принял решение (approved) и резолвер выбрал конкретный канал, intent попадает в реестр каналов (step-registry.ts) и выполняется по одному из двух сценариев:
- single-phase — последовательность синхронных
steps[], которые работают напрямую с TigerBeetle и/или внешним PSP. Каждый step возвращаетStepOutcome(continue/authorized/settled/void). - two-phase — двухфазный канал с явными методами
reserve/redeem/cancel/expire. Используется для инвойсов и любых сценариев, где между «созданием обязательства» и «исполнением платежа» проходит время и нужна логика TTL/отмены.
Канал регистрируется в step-registry.ts через registerChannel(channel) и резолвится по имени (channel.name).
StepOutcome — короткая семантика¶
В single-phase каналах каждый step возвращает StepOutcome, который handler интерпретирует так:
{ done: 'continue' }— продолжай: либо к следующему step-у, либо (если это последний step) — отдай клиенту201 AUTHORIZED + requiresMonitoring=true. Используется вIPPS_TRANSFER, где settle произойдёт асинхронно.{ done: 'authorized', tbTransferIds }— платёж зарезервирован в TB, дальнейшая работа — на следующем step-е.{ done: 'settled', tbTransferIds }— деньги уже окончательно учтены, intent закрыт со статусомSUCCEEDED.{ done: 'void' }— отмена/возврат внутри саги; intent переходит вFAILED/CANCELEDв зависимости от причины.
В two-phase канале redeem() возвращает тот же StepOutcome — поэтому handler может переиспользовать общую ветку обработки результата.
Реестр каналов¶
| Код | Фаза | Краткое описание | Файл |
|---|---|---|---|
INTERNAL_P2P |
single-phase | Внутренний P2P в TB: authorize + settle в одном HTTP запросе. | src/channels/internal-p2p.ts |
IPPS_TRANSFER |
single-phase | Outbound на PromptPay через IPPS PSP; async settle через psp_tx_map. |
src/channels/ipps-transfer.ts |
SERVICE_TRANSFER |
single-phase | Сервисные движения средств (комиссии, депозиты, корректировки) через transit. | src/channels/service-transfer.ts |
ADMIN |
single-phase | Прямой админский перевод from→to без transit (single TB transfer). | src/channels/admin-transfer.ts |
MERCHANT_INVOICE |
two-phase | Мерчант-инвойс: reserve без TB (QR + TTL) → redeem запускает P2P сагу. | src/channels/merchant-invoice.ts |
Всего 5 каналов. Список исчерпывающий — других каналов в PM нет.
Дисклеймер: коллизии имён¶
В кодовой базе несколько систем именования пересекаются по словам, но обозначают разные сущности. Не путать:
- Канал
ADMIN≠operationTypeADMIN_TRANSFER. В файлеsrc/channels/admin-transfer.tsполеname = 'ADMIN'(а не'ADMIN_TRANSFER'). При этом операция называетсяADMIN_TRANSFERи описана вsrc/operation-types/admin-transfer.ts. Резолвер маппитADMIN_TRANSFER → ADMIN. - Канал
SERVICE_TRANSFER≠operationTypeSERVICE_DEPOSIT(и любой другойSERVICE_*operation). На каналSERVICE_TRANSFERмогут маршрутизироваться несколько operationType-ов (депозиты, корректировки), не только одноимённая операция. - Канал
IPPS_TRANSFER≠ operationType. Через каналIPPS_TRANSFERидут как минимумIPPS_WITHDRAWALиTHAI_QR_PAY(outbound на PromptPay-рельсы). Имя канала описывает рельсу, а не бизнес-смысл операции. IPPS,QP,WISE— этоPspName, НЕ channels. Это значения колонкиpsp_nameв таблицеpm.psp_tx_map, идентифицирующие конкретный PSP-движок, через который async-канал отрабатывает settle. СейчасIPPS_TRANSFERвсегда пишетpsp_name='IPPS'; multi-PSP роутинг (QP,WISE) появится в Plan 4+.
INTERNAL_P2P¶
Файл: src/channels/internal-p2p.ts
Фаза: single-phase
requiresTransit: true
tbPendingTimeout: 300 сек
Внутренний P2P-перевод между двумя TB-аккаунтами одного и того же ledger-а. Используется для всех «гражданских» переводов кошелёк→кошелёк, а также как базовый «двигатель» — шаги делегируются в _p2p-saga.ts (runP2pAuthorize / runP2pSettle), и его же переиспользует MERCHANT_INVOICE.redeem(). Это единственный канал, который физически реализует двухфазный TB-flow «через transit», и поэтому он же служит примером каноничного движения средств: from → transit (pending) → transit → to (post) с финальным transit.balance = 0.
Логика settle — синхронная: оба шага (authorize и settle) выполняются в рамках одного HTTP-запроса в POST /intents. На step 1 создаётся pending TB transfer через transit-аккаунт, на step 2 — post_pending. К моменту возврата ответа клиенту средства уже зачислены. tbPendingTimeout=300 сек — страховка от «зависшего» pending, если step 2 по какой-то причине не отработает (TB сам провернёт void через 5 минут).
Сюда роутятся внутренние операции типа P2P_TRANSFER (и любой operationType, у которого финальная стратегия — синхронный TB-перевод через transit).
IPPS_TRANSFER¶
Файл: src/channels/ipps-transfer.ts
Фаза: single-phase (но settle асинхронный)
tbPendingTimeout: 0 (TB pending НЕ автоистекает)
Исходящий платёж на внешние рельсы — PromptPay через PSP IPPS. Используется для operationType-ов IPPS_WITHDRAWAL (вывод на банковский счёт) и THAI_QR_PAY (оплата по QR-коду торговой точки).
Внутри только step 1 делает реальную работу: создаёт одиночный TB pending (user → system.nostro.ipps.{currency}) и вставляет строку в pm.psp_tx_map(state=NEW). Step 2 — no-op, возвращает { done: 'continue' }, что заставляет handler вернуть 201 AUTHORIZED + requiresMonitoring=true.
Settle отрабатывает асинхронно: PspWorker берёт запись из psp_tx_map под FOR UPDATE SKIP LOCKED, гоняет state-machine query+confirm, и по итогу применяет outcome в outbox. OutboxWorker уже выполняет в TB либо post_pending (success), либо void_pending (failure) и пишет tx_history. tbPendingTimeout=0 критичен: TB-pending не должен auto-expire, потому что только PSP может сказать, состоялся ли платёж в реальном мире.
Хардкод psp_name='IPPS' в step 1 — временный: multi-PSP роутинг (QP, WISE) переедет в channel-resolver в Plan 4+. До тех пор канал IPPS_TRANSFER фактически жёстко связан с IPPS-драйвером. Это и есть та самая «коллизия имён» из дисклеймера выше: канал называется IPPS_TRANSFER, но это рельса, а не PSP.
SERVICE_TRANSFER¶
Файл: src/channels/service-transfer.ts
Фаза: single-phase
tbPendingTimeout: 0
Тонкая обёртка вокруг internalP2PChannel.steps для сервисных движений средств: депозитов на пользовательские аккаунты, удержания комиссий, ручных корректировок и прочих внутренних потоков, инициированных не пользователем, а системой/админом. Семантически отличается от INTERNAL_P2P тем, что одна из сторон — не «гражданский» аккаунт, а сервисный (treasury, fee-pool, suspense и т. п.). На уровне реализации канал буквально импортирует internalP2PChannel.steps — это сделано намеренно, чтобы любые правки P2P-саги (например, изменение шаблона транзитного движения) автоматически распространялись на оба канала.
Логика settle — синхронная, идентичная INTERNAL_P2P (тот же _p2p-saga.ts). Транзит используется так же, как и в обычном P2P, и финальный инвариант transit.balance = 0 так же критичен. Главное отличие в политиках/правах: SERVICE_TRANSFER доступен только внутренним вызовам (HMAC X-Service-Id от Auth Center / Admin Panel), и rule-engine для этих операций обычно прогоняет другой набор policy-pack-ов.
Сюда роутятся SERVICE_DEPOSIT, SERVICE_FEE, корректирующие/возвратные операции — все, у кого по политике резолвится channel=SERVICE_TRANSFER.
ADMIN¶
Файл: src/channels/admin-transfer.ts
Фаза: single-phase
requiresTransit: false
tbPendingTimeout: 0
Имя в реестре: ADMIN (НЕ ADMIN_TRANSFER)
Прямой админский перевод from → to без transit-аккаунта. Создаёт одиночный non-pending TB transfer (flags=0), без двухфазного pending → post. Это сознательное отступление от P2P-инварианта «через transit»: админские операции обычно компенсирующие/корректирующие, для них баланс transit на промежуточном шаге не нужен.
Логика settle — синхронная и моментальная: step 1 создаёт TB transfer и возвращает done: 'authorized', step 2 возвращает done: 'settled' без дополнительной работы. К моменту ответа клиенту обе ноги уже посчитаны в TB.
Сюда роутится operationType ADMIN_TRANSFER (см. src/operation-types/admin-transfer.ts). Из-за отсутствия pending-фазы ADMIN не может быть «частично отменён» — отмена реализуется отдельной компенсирующей операцией ADMIN_TRANSFER с обратной парой from/to.
Особое предостережение: канал
ADMINНЕ требует transit, и поэтому он не подчиняется правилуtransit.balance = 0(этого инварианта в его flow физически нет). Это исключение из общего паттерна PM и единственное место, где допустим прямойfrom → toTB-transfer.
MERCHANT_INVOICE¶
Файл: src/channels/merchant-invoice.ts
Фаза: two-phase
tbPendingTimeout: 0 (не используется — на reserve TB не задействован)
Канал мерчант-инвойсов: торговая точка генерирует QR с подписью, покупатель сканирует и подтверждает оплату. Между этими событиями проходит ненулевое время (типично — секунды-минуты), поэтому используется не steps[], а полный TwoPhase API.
reserve(ctx)— UPDATE intent: ставитexpiresAt,reservedAt, генеритqrSignatureчерезsignQrPayload. TigerBeetle не задействован: на этом этапе ещё нет ни pending, ни post.redeem(ctx)— покупатель подтвердил платёж: запускается P2P-сага (runP2pSagaиз_p2p-saga.ts), которая делает authorize + settle в TB одним проходом. По сути redeem =INTERNAL_P2Pповерх уже зарезервированного инвойса.cancel(ctx)— optimistic UPDATEstatus='CANCELED' WHERE status='CREATED'. Если intent уже неCREATED(например, его успели redeem или expire), кидаетConflictError('CANNOT_CANCEL'). По успеху публикует событие в Redis.expire(ctx)— вызывается invoice-expiry sweeper. Atomic UPDATEstatus='EXPIRED' WHERE status='CREATED' AND expires_at < now(). No-op, если intent уже завершён.
Сюда роутится operationType INVOICE_PAYMENT. Важно: reserve() не делает HMAC-проверки QR — это задача внешнего слоя (handler-а или confirm endpoint-а), который должен убедиться, что подпись qrSignature соответствует параметрам платежа до того, как зовёт redeem().
Регистрация и резолюция канала¶
Все 5 каналов регистрируются на старте процесса в общем реестре step-registry.ts:
// src/intent/step-registry.ts
const registry = new Map<string, Channel>()
export function registerChannel(channel: Channel): void { registry.set(channel.name, channel) }
export function getChannel(name: string): Channel { /* throws if missing */ }
Каждый канал регистрируется через registerChannel(channel) при инициализации сервера (server.ts). Имя берётся напрямую из channel.name — никакого alias-маппинга в реестре нет, поэтому опечатка типа 'ADMIN_TRANSFER' вместо 'ADMIN' приведёт к Channel not registered. Резолюция канала из operationType происходит выше реестра — в политиках/резолвере, который кладёт итоговый channel в intent перед вызовом handler-а.
Из этого устройства следуют практические инварианты:
- Канал — глобальный singleton в рамках процесса; внутри
steps[]нельзя хранить mutable per-request состояние, всё — черезIntentContext. - Имя канала (
channel.name) — стабильный публичный идентификатор: оно попадает в логи, вtx_history, в события Redis. Переименование канала — breaking change. clearRegistry()существует только для тестов (vitest); в продакшне реестр заполняется ровно один раз.
TwoPhaseChannel interface — зачем¶
SinglePhaseChannel хорошо ложится на сценарии, где платёж исполняется одним залпом: пришёл запрос — отработали steps[] — ответили. Но мерчант-инвойсы (и шире — любой сценарий, где между «заявил намерение оплатить» и «оплатил на самом деле» проходит time-window) требуют другого жизненного цикла:
reserve(ctx: ReserveContext): Promise<ReserveOutcome>— зафиксировать обязательство (QR, TTL, идемпотентность), но не двигать деньги в TB. В этом ключевое отличие от TB-pending: pending уже резервирует баланс в TB, а reserve — это просто строка в PostgreSQL сexpiresAtиqrSignature.ReserveContextнесёт сам intent, TTL иappScope(w/c/a— wallet / checkout / admin), потому что подпись QR должна различаться по сценарию использования.redeem(ctx: IntentContext): Promise<StepOutcome>— провести платёж по реальному факту. Только здесь начинается работа с TB. Сигнатура совпадает с обычным step изSinglePhaseChannel, что позволяет переиспользовать_p2p-saga.tsбез адаптеров.cancel(ctx: CancelContext): Promise<void>— явная отмена обязательства плательщиком/мерчантом до redeem. Optimistic concurrency: атомарный UPDATE «только изCREATED», иначеConflictError('CANNOT_CANCEL'). Контекст несётreasonиactorUserIdдля аудита.expire(ctx: ExpireContext): Promise<void>— пассивное протухание по TTL, вызывается фоновым sweeper-ом (invoice-expiry). Аналогично cancel, но триггер —expires_at < now(), а не явный запрос. No-op, если intent уже не вCREATED.
Дискриминатор kind: 'two-phase' (см. src/channels/_types.ts) + type-guard isTwoPhase(c) позволяют handler-у выбирать правильный путь исполнения для каждого канала без instanceof или магических полей. Single-phase каналы либо явно ставят kind: 'single-phase', либо опускают поле — union сужается корректно в обоих случаях. Реестр (step-registry.ts) хранит и те и другие в общей Map<string, Channel> — это позволяет иметь единый entry-point getChannel(name) независимо от фазы.
Главный архитектурный смысл TwoPhase API в том, что TB-аккаунты не должны простаивать в pending-состоянии часами. У TB-pending есть timeout, и держать там «зарезервированный QR» на 15 минут — это и нагрузка на TB cluster (state машина), и риск нежелательного auto-void. Двухфазный канал переносит «холд» в PostgreSQL (дешёвая UPDATE-семантика), а TB задействуется только в момент реального redeem.
См. также¶
03-operation-types.md— таблица operationType → channel.../../modules/channels.md— (TODO, Phase 3) — описание step-registry, диспатча single-phase vs two-phase в handler-е и взаимодействия с outbox.../../modules/channels.md— общая P2P-сага, переиспользуемая вINTERNAL_P2P,SERVICE_TRANSFERиMERCHANT_INVOICE.redeem.
TODO: более глубокое описание см. в
../../modules/channels.md(на момент написания паспорта документ ещё не создан — добавится в Phase 3).