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

Двухфазные каналы платежей

Архитектурный документ описывает интерфейс 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>

interface CancelContext {
  intent:      Intent
  reason:      string
  actorUserId: number
}

Мерчант (или служба) явно отменяет инвойс. Реализовано как atomic UPDATE … WHERE status='CREATED' — если статус уже изменился (оплачен, истёк, отменён), кидает ConflictError('CANNOT_CANCEL'). Публикует CANCELED в Redis для real-time подписчиков.

expire(ctx: ExpireContext): Promise<void>

interface ExpireContext { intent: Intent }

Вызывается фоновым 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 сознательно сделан полноценным интерфейсом, хотя сейчас у него ровно одна реализация:

  1. Изоляция инвариантов канала. Логика optimistic UPDATE’ов (status='CREATED' guard, кидание CANNOT_CANCEL, no-op expire) — это контракт канала, а не handler’а. Handler ничего не знает про статусную диаграмму инвойса.
  2. Открытая дверь для будущих двухфазных каналов — например, IPPS pre-authorization, где TB pending нужен сразу, но settle отложен; или автоплатежи с подтверждением push-уведомлением. Они переиспользуют ту же четвёрку методов и тот же guard.
  3. Type-safe диспетчеризация. isTwoPhase() гарантирует на уровне компилятора, что handler не вызовет channel.steps[0]() на инвойсе и не попытается дернуть channel.reserve() на INTERNAL_P2P.

Связанные документы

  • Channels module — реестр каналов, регистрация, resolver маршрутизации.