Двухфазные каналы платежей¶
Архитектурный документ описывает интерфейс TwoPhaseChannel, его назначение,
методы жизненного цикла (reserve / redeem / cancel / expire) и контраст с
обычными single-phase каналами. На сегодня в PM существует ровно один такой
канал — MERCHANT_INVOICE, но интерфейс спроектирован под расширение.
1. Назначение¶
Single-phase канал (INTERNAL_P2P, IPPS_TRANSFER, …) — это синхронный pipeline
из steps[], который в одном HTTP-запросе резервирует средства в TigerBeetle
(pending transfer) и сразу же завершает оплату (post transfer). Такая модель
подходит, когда плательщик известен и готов платить прямо сейчас.
Однако для сценариев типа «мерчант выставил инвойс — покупатель оплатит когда вспомнит про QR» эта модель ломается:
- TB pending transfer держит средства заблокированными на стороне плательщика;
- инвойс может ждать оплаты часами, а TB pending имеет ограниченный timeout;
- на момент создания инвойса плательщик вообще ещё неизвестен (
fromAccountName = null).
Чтобы развязать «создание оферты» и «фактический перевод денег», введён
интерфейс TwoPhaseChannel — два независимых TB-перехода, разнесённых во
времени.
2. Интерфейс TwoPhaseChannel¶
Определён в src/channels/_types.ts:
export 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>
}
2.1. Поля¶
| Поле | Назначение |
|---|---|
kind |
Литерал 'two-phase' — дискриминатор union’а Channel. |
name |
Имя канала в реестре (MERCHANT_INVOICE). |
tbPendingTimeout |
Унаследовано от single-phase. Для текущего MERCHANT_INVOICE = 0 — reserve() вообще не создаёт TB pending; поле зарезервировано для будущих каналов, где двухфазность всё-таки потребует TB-резерва. |
2.2. Методы жизненного цикла¶
reserve(ctx: ReserveContext): Promise<ReserveOutcome>¶
interface ReserveContext {
intent: Intent
ttlSeconds: number
appScope: 'w' | 'c' | 'a' // wallet | checkout | admin
}
interface ReserveOutcome {
expiresAt: Date
qrSignature: Buffer
}
Создаёт «холд» для интента: пишет в БД expiresAt, qrSignature, reservedAt.
TigerBeetle на этом шаге не задействован (для MERCHANT_INVOICE), потому что
плательщик ещё не известен. Возвращает данные, нужные клиенту, чтобы построить
QR-код (подпись + дедлайн).
redeem(ctx: IntentContext): Promise<StepOutcome>¶
Покупатель отсканировал QR и подтвердил оплату — redeem() запускает обычную
P2P-сагу (runP2pSaga) и проводит деньги в TigerBeetle: authorize → settle.
С этого момента flow ничем не отличается от обычного single-phase интента.
cancel(ctx: CancelContext): Promise<void>¶
Мерчант (или служба) явно отменяет инвойс. Реализовано как atomic
UPDATE … WHERE status='CREATED' — если статус уже изменился (оплачен,
истёк, отменён), кидает ConflictError('CANNOT_CANCEL'). Публикует
CANCELED в Redis для real-time подписчиков.
expire(ctx: ExpireContext): Promise<void>¶
Вызывается фоновым sweeper’ом (invoice-expiry). Atomic
UPDATE … WHERE status='CREATED' AND expires_at < now(). Если интент уже
не в CREATED — no-op (без ошибки): нормальная гонка с оплатой/cancel.
3. Discriminated union Channel¶
В реестре каналов хранятся оба варианта под одним типом:
// src/channels/_types.ts
export interface SinglePhaseChannel extends LegacyChannel { kind?: 'single-phase' }
export type Channel = SinglePhaseChannel | TwoPhaseChannel
Дискриминатор — поле kind. Для совместимости с уже существующими каналами
kind у SinglePhaseChannel опционален (отсутствие = 'single-phase').
Type guard isTwoPhase()¶
Сужает Channel до TwoPhaseChannel:
// src/channels/_types.ts
export function isTwoPhase(c: Channel): c is TwoPhaseChannel {
return (c as TwoPhaseChannel).kind === 'two-phase'
}
Реэкспортируется из src/intent/step-registry.ts, чтобы потребители (handler,
тесты) импортировали его рядом с getChannel().
Использование в src/intent/handler.ts:
const channel = getChannel(channelName)
…
if (isTwoPhase(channel)) {
const reserved = await channel.reserve({ intent, ttlSeconds, appScope })
return reply.status(201).send({ … expiresAt, qrSignature … })
}
// дальше — обычный single-phase flow со steps[]
После guard’а TypeScript «знает», что у channel есть .reserve() и нет
.steps[].
4. Поток reserve → redeem (MERCHANT_INVOICE)¶
sequenceDiagram
autonumber
actor Merchant
actor Customer
participant PM as Payment Manager
participant TB as TigerBeetle
Merchant->>PM: POST /intents (operationType=INVOICE_PAYMENT)
PM->>PM: getChannel('MERCHANT_INVOICE') → isTwoPhase = true
PM->>PM: channel.reserve({ ttlSeconds, appScope })
Note over PM: INSERT intent CREATED<br/>UPDATE expiresAt + qrSignature<br/>(БЕЗ обращения к TB)
PM-->>Merchant: 201 { intentId, qrSignature, expiresAt }
Note over Customer,PM: проходит время — минуты или часы
Customer->>PM: POST /intents/:id/confirm (HMAC, fromAccountName)
PM->>PM: channel.redeem(ctx)
PM->>TB: createTransfers (P2P authorize: pending)
PM->>TB: createTransfers (P2P settle: post pending)
TB-->>PM: ok
PM-->>Customer: 200 SETTLED
PM-->>Merchant: Redis publish intent.{id} = SETTLED
Альтернативные ветки:
cancel()— мерчант жмёт «отменить»;UPDATE status=CANCELED WHERE status=CREATED. Если покупатель уже подтвердил оплату —CANNOT_CANCEL.expire()— sweeper по таймеру:UPDATE status=EXPIRED WHERE expires_at < now().
5. Контраст с single-phase¶
INTERNAL_P2P (src/channels/internal-p2p.ts) — классический single-phase:
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),
],
}
Обе TB-операции (authorize + settle) исполняются последовательно в одном
HTTP-запросе POST /intents. К моменту ответа клиенту интент уже в
SETTLED. Плательщик и получатель оба известны, средства не висят в pending
дольше времени HTTP-запроса.
MERCHANT_INVOICE разделяет это во времени:
| Атрибут | INTERNAL_P2P (single) |
MERCHANT_INVOICE (two-phase) |
|---|---|---|
TB pending в reserve |
— | Нет (только запись в БД) |
| Кто инициирует TB | Плательщик в POST /intents |
Покупатель в POST /intents/:id/confirm |
fromAccountName |
Обязателен на create | null до confirm |
| Время жизни | < секунды | Часы (TTL из ReserveContext.ttlSeconds) |
| Отмена | Только через FAILED-rollback саги | Явный cancel() или фоновый expire() |
Поле kind |
'single-phase' (или отсутствует) |
'two-phase' |
6. Зачем интерфейс, а не «private case в handler.ts»¶
TwoPhaseChannel сознательно сделан полноценным интерфейсом, хотя сейчас у
него ровно одна реализация:
- Изоляция инвариантов канала. Логика optimistic UPDATE’ов
(
status='CREATED'guard, киданиеCANNOT_CANCEL, no-opexpire) — это контракт канала, а не handler’а. Handler ничего не знает про статусную диаграмму инвойса. - Открытая дверь для будущих двухфазных каналов — например, IPPS pre-authorization, где TB pending нужен сразу, но settle отложен; или автоплатежи с подтверждением push-уведомлением. Они переиспользуют ту же четвёрку методов и тот же guard.
- Type-safe диспетчеризация.
isTwoPhase()гарантирует на уровне компилятора, что handler не вызоветchannel.steps[0]()на инвойсе и не попытается дернутьchannel.reserve()наINTERNAL_P2P.
Связанные документы¶
- Channels module — реестр каналов, регистрация, resolver маршрутизации.