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

Cookbook: Добавить новый платёжный канал

Краткое руководство по добавлению нового канала (Channel) в Payment Manager. Канал — это объект с именем и логикой исполнения платёжной операции. Каналы регистрируются в реестре (step-registry.ts) и подбираются rule-engine'ом через payment_route (см. ./add-payment-route.md).

1. Когда использовать

Используйте этот рецепт, если:

  • Нужно добавить новый PSP / внутренний flow (например, IPPS_PAYIN, QR_PROMPTPAY, MERCHANT_REFUND).
  • Нужен новый сценарий двухфазной оплаты (reserve → redeem).
  • Нужен новый административный канал (например, ADMIN для ручных корректировок).

Не используйте, если:

  • Меняется только маршрутизация (новый PSP для уже существующего канала) — это новый payment_route, см. ./add-payment-route.md.
  • Меняется только набор шагов внутри существующего канала — отредактируйте steps[] напрямую.

2. Выбор типа канала

Канал бывает двух видов — выбор делается ДО написания кода:

Тип Когда Интерфейс Пример
SinglePhaseChannel Синхронный flow: одна операция, один TB-flow, ответ сразу после settle. steps: StepFn[] src/channels/internal-p2p.ts
TwoPhaseChannel Reserve сейчас, redeem потом (инвойс, hold, QR с TTL). reserve / redeem / cancel / expire src/channels/merchant-invoice.ts

Подробнее про двухфазные каналы — ../architecture/04-two-phase-channels.md.

3. Single-phase: создать канал

3.1. Файл src/channels/<name>.ts

Образец — src/channels/internal-p2p.ts. Минимум:

/**
 * <NAME>: краткое описание flow (что делает, какие TB transfers создаёт).
 */
import type { SinglePhaseChannel } from './_types.js'

export const myChannel: SinglePhaseChannel = {
  kind:             'single-phase',
  name:             'MY_CHANNEL',         // имя должно быть UPPER_SNAKE_CASE
  tbPendingTimeout: 300,                  // секунд до auto-void pending TB transfer'а
  requiresTransit:  true,                 // если flow затрагивает transit-аккаунт
  steps: [
    async (ctx) => { /* шаг 1: authorize (pending TB transfer) */ return { done: 'continue' } },
    async (ctx) => { /* шаг 2: settle (post pending) */          return { done: 'ok' } },
  ],
}

Контракт шагов:

  • Каждый step принимает IntentContext и возвращает StepOutcome.
  • { done: 'continue' } — переход к следующему шагу. { done: 'ok' } — завершение саги успешно. { done: 'failed', reason } — провал.
  • Шаги вызываются по порядку; между ними возможны retries и persisted-state (см. ../modules/intent.md).

3.2. Что делегировать в _p2p-saga.ts

Если flow — это P2P внутри TigerBeetle (как INTERNAL_P2P), переиспользуйте runP2pAuthorize / runP2pSettle вместо копирования TB-вызовов.

4. Two-phase: создать канал

4.1. Файл src/channels/<name>.ts

Образец — src/channels/merchant-invoice.ts. Минимум:

import type {
  TwoPhaseChannel, ReserveContext, ReserveOutcome,
  CancelContext, ExpireContext,
} from './_types.js'
import type { IntentContext, StepOutcome } from '../intent/types.js'

export const myInvoiceChannel: TwoPhaseChannel = {
  kind:             'two-phase',
  name:             'MY_INVOICE',
  // tbPendingTimeout = 0, если reserve() не создаёт pending TB transfer (только запись в БД).
  tbPendingTimeout: 0,

  async reserve(ctx: ReserveContext): Promise<ReserveOutcome> {
    // 1. Посчитать expiresAt = now + ctx.ttlSeconds.
    // 2. (Опционально) подписать QR-payload через signQrPayload().
    // 3. UPDATE intent SET expiresAt, qrSignature, reservedAt WHERE id = ctx.intent.id.
    // 4. Вернуть { expiresAt, qrSignature }.
    throw new Error('not implemented')
  },

  async redeem(ctx: IntentContext): Promise<StepOutcome> {
    // Запустить TB-сагу (обычно runP2pSaga(ctx)) — это и есть «реальный» платёж.
    throw new Error('not implemented')
  },

  async cancel(ctx: CancelContext): Promise<void> {
    // Atomic UPDATE status='CANCELED' WHERE status='CREATED' (optimistic concurrency).
    // Если updated.length === 0 — throw ConflictError('CANNOT_CANCEL', ...).
    // Опубликовать в Redis: publishIntentStatus(intentId, 'CANCELED').
  },

  async expire(ctx: ExpireContext): Promise<void> {
    // Atomic UPDATE status='EXPIRED' WHERE status='CREATED' AND expires_at < now().
    // No-op (return), если ничего не обновили — инвойс уже завершён.
    // Опубликовать в Redis: publishIntentStatus(intentId, 'EXPIRED').
  },
}

4.2. Инварианты two-phase канала

  • reserve() не должен трогать TigerBeetle — только запись в pm.intent. TB включается в redeem().
  • cancel() / expire() должны быть идемпотентными через WHERE status='CREATED' — это защита от гонок.
  • Любая смена статуса должна публиковаться в Redis через publishIntentStatus() — иначе клиенты не получат real-time обновление.
  • version: sql\${intentTable.version} + 1`` обязателен для optimistic locking.

5. Зарегистрировать канал

5.1. Реестр

В src/intent/step-registry.ts экспортируется registerChannel(channel) — он принимает любой Channel (single или two-phase) и кладёт в Map<name, channel>. Сам реестр изменять не нужно.

5.2. Регистрация в server.ts

Импортируйте новый канал и вызовите registerChannel() при старте сервера:

// src/server.ts
import { registerChannel } from './intent/step-registry.js'
import { myChannel } from './channels/my-channel.js'

// ...рядом с другими registerChannel(...)
registerChannel(myChannel)

После этого getChannel('MY_CHANNEL') возвращает ваш канал, и rule-engine может его выбрать.

6. Создать payment_route

Канал без payment_route не используется — rule-engine не узнает, при каких условиях его выбирать. См. ./add-payment-route.md:

  • Какие условия (operation_type, currency, лимиты).
  • Какой channel = 'MY_CHANNEL'.
  • Приоритет (priority) относительно других маршрутов.

7. Обновить документацию

После добавления канала обязательно:

  1. ../reference/passport/02-channels.md — добавить запись с именем канала, типом (single/two-phase), TB-flow.
  2. ../modules/channels.md — добавить раздел с описанием, ссылкой на исходник.
  3. ../architecture/04-two-phase-channels.md — обновить, если канал двухфазный (lifecycle, состояния, sweeper).

8. Тесты

Образцы — в test/channels/:

  • Single-phase: test/channels/internal-p2p.test.ts (если есть; иначе используйте test/intent/handler.test.ts как pattern).
  • Two-phase: test/channels/merchant-invoice.test.ts — проверяет reserve(), cancel() happy/race-path, expire() idempotency.

Минимальный набор:

  • reserve() создаёт запись с правильным expiresAt и qrSignature (для two-phase).
  • redeem() доходит до TB-саги и intent.status = 'SETTLED'.
  • cancel() из CREATEDCANCELED; повторный вызов → ConflictError('CANNOT_CANCEL').
  • expire() срабатывает только при expiresAt < now() и status='CREATED'.
  • Любая смена статуса публикуется в Redis (мок publishIntentStatus).

9. CHANGELOG

В CHANGELOG.md корня payment-manager/ добавьте запись:

## [Unreleased]
### Added
- channel `MY_CHANNEL` (two-phase): краткое описание flow и use-case.

10. Чек-лист

  • Выбран тип канала (single-phase или two-phase) — обоснован.
  • Создан src/channels/<name>.ts с экспортом <name>Channel.
  • Имя канала — UPPER_SNAKE_CASE, без слова _TRANSFER (это operationType, не channel).
  • Зарегистрирован через registerChannel() в src/server.ts.
  • Создан payment_route (./add-payment-route.md).
  • Обновлён ../reference/passport/02-channels.md.
  • Обновлён ../modules/channels.md.
  • (Если two-phase) обновлён ../architecture/04-two-phase-channels.md.
  • Написаны тесты по образцу test/channels/merchant-invoice.test.ts.
  • Обновлён CHANGELOG.md.

Подводные камни

  • Не путайте канал и operationType. ADMIN — это канал (запись ручной корректировки), ADMIN_TRANSFER — это operationType (тип intent'а). Они НЕ одно и то же; payment_route связывает их по условиям.
  • tbPendingTimeout для two-phase = 0, если reserve() не создаёт pending TB transfer. Не копируйте значение из single-phase бездумно.
  • requiresTransit включается только если flow реально проходит через transit-аккаунт (см. инвариант transit.balance = 0 в архитектурных правилах PM).
  • Идемпотентность cancel() и expire() держится на WHERE status='CREATED'. Не упрощайте до безусловного UPDATE — будут гонки между sweeper'ом и юзером.
  • publishIntentStatus() обязателен при любой смене статуса — иначе SSE/streamStatus клиента не получат обновление.

Ссылки