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. Обновить документацию¶
После добавления канала обязательно:
../reference/passport/02-channels.md— добавить запись с именем канала, типом (single/two-phase), TB-flow.../modules/channels.md— добавить раздел с описанием, ссылкой на исходник.../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()изCREATED→CANCELED; повторный вызов →ConflictError('CANNOT_CANCEL').expire()срабатывает только приexpiresAt < now()иstatus='CREATED'.- Любая смена статуса публикуется в Redis (мок
publishIntentStatus).
9. CHANGELOG¶
В CHANGELOG.md корня payment-manager/ добавьте запись:
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 клиента не получат обновление.