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

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 нет.

Дисклеймер: коллизии имён

В кодовой базе несколько систем именования пересекаются по словам, но обозначают разные сущности. Не путать:

  • Канал ADMINoperationType ADMIN_TRANSFER. В файле src/channels/admin-transfer.ts поле name = 'ADMIN' (а не 'ADMIN_TRANSFER'). При этом операция называется ADMIN_TRANSFER и описана в src/operation-types/admin-transfer.ts. Резолвер маппит ADMIN_TRANSFER → ADMIN.
  • Канал SERVICE_TRANSFERoperationType SERVICE_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 → to TB-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 UPDATE status='CANCELED' WHERE status='CREATED'. Если intent уже не CREATED (например, его успели redeem или expire), кидает ConflictError('CANNOT_CANCEL'). По успеху публикует событие в Redis.
  • expire(ctx) — вызывается invoice-expiry sweeper. Atomic UPDATE status='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) требуют другого жизненного цикла:

  1. 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 должна различаться по сценарию использования.
  2. redeem(ctx: IntentContext): Promise<StepOutcome> — провести платёж по реальному факту. Только здесь начинается работа с TB. Сигнатура совпадает с обычным step из SinglePhaseChannel, что позволяет переиспользовать _p2p-saga.ts без адаптеров.
  3. cancel(ctx: CancelContext): Promise<void> — явная отмена обязательства плательщиком/мерчантом до redeem. Optimistic concurrency: атомарный UPDATE «только из CREATED», иначе ConflictError('CANNOT_CANCEL'). Контекст несёт reason и actorUserId для аудита.
  4. 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).