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

Модуль intent

Ядро платёжного оркестратора: HTTP-обработчики, диспетчер каналов, контекст-билдер, outbox-воркер и реконсилер сага.

1. Назначение

Модуль src/intent/* — это сердце Payment Manager. Он:

  • принимает входящие платёжные запросы (POST /intents, POST /intents/:id/confirm, POST /intents/:id/cancel, POST /intents/quote);
  • проверяет idempotency, права сервисного ключа, лимиты пользователя;
  • разрешает payment channel по pm.payment_route (см. router.ts);
  • собирает IntentContext (TB-аккаунты + комиссии) и запускает шаги канала из src/channels/*;
  • логирует каждый переход статуса в pm.intent_event и при необходимости публикует в Redis канал intent.{id};
  • асинхронно достраивает PSP-сценарии через pm.outbox_event и setInterval-воркер.

Кто вызывает:

  • Auth Center (Serverpod) — основной потребитель /intents для пользовательских платежей и /intents/quote для предварительной оценки комиссий.
  • nginx-прокси /api/pm/* — пробрасывает запросы фронта с HMAC-подписью service-id flutter-app.
  • Mini-app бэкенды — выпуск инвойсов (INVOICE_PAYMENT, два этапа: create → confirm) и снятия комиссий.

С чем взаимодействует:

  • src/channels/* — стратегии исполнения (INTERNAL_P2P, IPPS_TRANSFER, THAI_QR_PAY, MERCHANT_INVOICE, SERVICE_TRANSFER, ADMIN).
  • src/ledger/* — резолвинг аккаунтов и записи в TigerBeetle.
  • src/limits/*checkLimits() перед первой TB-операцией.
  • src/rule-engine/* — расчёт PRE/POST-комиссий.
  • src/operations/* — registry-определения operationType (P2P_TRANSFER, MINIAPP_CHARGE, INVOICE_PAYMENT и др.).
  • shared/db, shared/redis, shared/tb, shared/logger, shared/config.

2. Структура файлов

Все файлы лежат в src/intent/. Полный листинг (17 файлов):

Файл Что делает
handler.ts Fastify-роуты POST /intents, GET /intents/:id, GET /intents/:id/events — главный entry-point для создания платежа.
confirm-handler.ts Fastify-роут POST /intents/:id/confirm — покупатель подтверждает MERCHANT_INVOICE, запускает TwoPhaseChannel.redeem().
cancel-handler.ts Fastify-роут POST /intents/:id/cancel — мерчант отменяет инвойс через TwoPhaseChannel.cancel().
quote.ts Fastify-роут POST /intents/quote — preview PRE-комиссий без записи в БД.
router.ts Функция resolveChannel(operationType, amount, db), выбирает channel по таблице pm.payment_route. Это НЕ HTTP-routes, несмотря на имя файла.
context-builder.ts buildIntentContext() — собирает IntentContext с TB-account IDs и transit-аккаунтом, переиспользуется confirm-handler.
from-account-override.ts validateFromAccountOverride() — проверяет, что override fromAccountName/toAccountName укладывается в разрешения сервисного ключа (allowedTypes, namePattern).
intent-events.ts writeIntentEvent() пишет переход в pm.intent_event; publishIntentStatus() шлёт сообщение в Redis канал intent.{id}.
notify.ts publishPaymentNotification() — публикует FCM-нотификации в Redis stream stream.notifications.jobs (SETTLED/FAILED).
operation-registry.ts Map name → OperationTypeDefinition. Простой in-memory registry, инициализируется при старте.
operation-type.ts Zod-схема BaseIntentBody + интерфейс OperationTypeDefinition (parseBody, resolveAccounts, preValidate, projectHistory).
outbox-worker.ts processOutboxBatch() + startOutboxWorker(intervalMs)setInterval-based воркер, добивает PSP-кейсы (post_pending, void_pending).
saga-runner.ts Универсальный runSaga(channel, ctx) — итерирует channel.steps[] пока шаг не вернёт final outcome.
settlement-writer.ts writeSettlement() — пишет 2-3 строки pm.tx_history (DEBIT/CREDIT/fee) с проекцией метаданных через projectHistory.
startup-reconciler.ts reconcile(db) — на старте PM находит stale-интенты (CREATED/AUTHORIZED > 5 минут) и либо войдит pending TB-трансферы, либо помечает FAILED.
step-registry.ts Registry channels (registerChannel, getChannel) + реэкспорт isTwoPhase() guard из channels/_types.
types.ts IntentRecord, IntentContext, StepOutcome, Step, Channel — TS-сигнатуры для шагов саги.
errors.ts Доменные ошибки NoRouteError, AccountNotFoundError, TBTransferError, PendingExpiredError, IppsNotRegisteredError, IppsMetadataInvalidError.

3. Ключевые типы и интерфейсы

// src/intent/types.ts:5
interface IntentRecord {
  id:              string
  serviceId:       string
  userId:          number
  operationType:   string
  channel:         string
  fromAccountName: string
  toAccountName:   string
  amount:          bigint
  currency:        string
  tbTransferIds:   string[]
  status:          IntentStatus       // 'CREATED' | 'VALIDATED' | 'AUTHORIZED' |
                                       // 'SETTLING' | 'SETTLED' | 'FAILED' |
                                       // 'MANUAL_REVIEW' | 'CANCELED' | 'EXPIRED'
  preFeeAmount:    bigint
  postFeeAmount:   bigint
  metadata:        Record<string, unknown>
  // ...
}

// src/intent/types.ts:28
interface IntentContext {
  intent:        IntentRecord
  channel:       Channel
  fromAccountId: bigint   // TigerBeetle u128 account id
  toAccountId:   bigint
  transitId:     bigint   // 0n если канал не требует transit
  tbTransferIds: string[]
  feeSplits:     ResolvedFeeSplit[]
}

// src/intent/types.ts:38
type StepOutcome =
  | { done: 'continue' }
  | { done: 'settled';    tbTransferIds: string[] }
  | { done: 'authorized'; tbTransferIds: string[]; pendingFor?: string }

// src/intent/types.ts:45
interface Channel {
  name:              string
  tbPendingTimeout:  number        // 0 = no auto-void, >0 = pending expires in N seconds
  steps:             Step[]
  requiresTransit?:  boolean       // default true
}

// src/intent/operation-type.ts:37
interface OperationTypeDefinition {
  name:            string
  parseBody(raw:   unknown): ParsedIntentBody
  resolveAccounts(input: ResolveAccountsInput):
    { fromAccountName: string | null; toAccountName: string | null }
  preValidate?(ctx: PreValidateContext): Promise<void>
  projectHistory?(metadata: Record<string, unknown>,
                  direction: 'DEBIT' | 'CREDIT'): Record<string, unknown>
}

4. Основные функции

Функция Файл:строка Краткое описание
resolveChannel(opType, amount, db) src/intent/router.ts:7 Выбирает channel по pm.payment_route (matches by operationType + amount range). Бросает NoRouteError если ничего не найдено.
buildIntentContext(args) src/intent/context-builder.ts:20 Принимает intent + channel + feeSplits + db, возвращает IntentContext с TB-account IDs. Требует, чтобы fromAccountName уже был известен.
writeIntentEvent(db, intentId, from, to, reason?, payload?, publishFn?) src/intent/intent-events.ts:13 Пишет строку в pm.intent_event и опционально публикует в Redis.
publishIntentStatus(intentId, status) src/intent/intent-events.ts:8 Публикует JSON {intentId, status, updatedAt} в Redis канал intent.{id}.
writeSettlement(db, rec, feeSplits) src/intent/settlement-writer.ts:63 Пишет DEBIT + CREDIT + fee-строки в pm.tx_history. Resolve userId для admin-flow через tb_account_map.
publishPaymentNotification(rec, status) src/intent/notify.ts:37 Публикует FCM-payload (sender + recipient) в Redis stream stream.notifications.jobs.
validateFromAccountOverride(acc, rule, field?) src/intent/from-account-override.ts:4 Проверяет, что override-аккаунт укладывается в allowedTypes/namePattern permissions сервисного ключа.
runSaga(channel, ctx) src/intent/saga-runner.ts:3 Итерирует channel.steps[] пока шаг не вернёт done !== 'continue'.
processOutboxBatch() src/intent/outbox-worker.ts:267 Берёт до 10 pending outbox_event, на каждой выполняет post_pending/void_pending TB-операции + writeSettlement + notify.
startOutboxWorker(intervalMs) src/intent/outbox-worker.ts:312 setInterval(processOutboxBatch, intervalMs). Возвращает NodeJS.Timeout.
reconcile(db) src/intent/startup-reconciler.ts:11 На старте PM ищет stale-интенты старше 5 минут; для internal каналов войдит pending TB-трансферы, для external — оставляет в MANUAL_REVIEW.
getOperationType(name) src/intent/operation-registry.ts:10 Lookup OperationTypeDefinition по имени, бросает BadRequestError если не зарегистрирован.
getChannel(name) src/intent/step-registry.ts:10 Lookup Channel по имени, бросает Error если не зарегистрирован.
isTwoPhase(channel) реэкспорт из channels/_types Type-guard для разделения single-phase vs two-phase каналов (MERCHANT_INVOICE).

Главный flow POST /intents (handler.ts)

1. parse body → operationType lookup → opDef.parseBody()
2. idempotency check (idempotencyKey + serviceId) → 200 если уже обработан
3. serviceKey.active + allowedOperationTypes + servicePermissions
4. resolveChannel(operationType, amount, db) → channel name
5. opDef.preValidate() — например, IPPS metadata check (IPPS_NOT_REGISTERED / IPPS_METADATA_INVALID)
6. opDef.resolveAccounts() — детерминированные from/to (НЕ-override path)
7. account overrides (если service key разрешает) → validateFromAccountOverride()
8. parallel: getAccountByName(from), getAccountByName(to), getAccountByName(transit)
8a. checkLimits() — fail-fast БЕЗ TB-write (LIMIT_EXCEEDED)
9. INSERT intent (status='CREATED') + writeIntentEvent('CREATED')
   ├─ isTwoPhase(channel) → channel.reserve() → 201 c QR-signature + expiresAt
   │   (TB-операций НЕТ; from-account неизвестен — заполнится в /confirm)
   └─ single-phase:
       10. calculateFees(PRE) + calculateFees(POST)
       11. UPDATE → 'VALIDATED' + writeIntentEvent
       12. channel.steps[0](ctx)  → 'AUTHORIZED' (TB pending создан) + writeIntentEvent
       13. channel.steps[1](ctx)
           ├─ outcome.done='settled' → UPDATE 'SETTLED' + writeSettlement + notify
           │   (синхронно для INTERNAL_P2P, SERVICE_TRANSFER, ADMIN)
           └─ outcome.done='authorized' → 201 c requiresMonitoring=true
               (для IPPS_TRANSFER / THAI_QR_PAY: outbox-worker завершит позже)

В случае любого исключения после INSERT intent — try/catch помечает intent как FAILED + пишет intentEvent('FAILED', reason). Ошибки самого UPDATE/event-INSERT логируются, но не маскируют исходное исключение — throw err доезжает до Fastify error-handler. См. handler.ts:435-445.

Ключевой момент диспетчеризации: после шага 9 проверяется isTwoPhase(channel). Если канал двухфазный — управление передаётся TwoPhaseChannel.reserve() (только сохраняет QR-сигнатуру и expiresAt, без TB-операций). Single-phase каналы идут по «классической» саге с двумя шагами (authorizesettle/pending).

Жёсткое правило: PM не авто-резолвит fromAccount по userId

handler.ts ожидает, что вызывающая сторона (Auth Center, mini-app backend) передаст явный fromAccountName в теле, либо operationType сам определит fromAccountName через opDef.resolveAccounts(). PM никогда не «угадывает» аккаунт по X-User-Id — попытка вызвать /intents без явного source-account для операции, где resolveAccounts() вернул null, упадёт в BadRequestError('fromAccountName is required for this operation type'). Это сознательное архитектурное решение: ответственность за выбор кошелька лежит на caller'е, у которого есть контекст (валюта, тип, multi-account политика).

Соответственно для определения direction лимита PM использует tb_account_map.userId, а не имя аккаунта или роль вызывающего: limitDirection = fromInfo.userId === userId ? 'DEBIT' : 'CREDIT' (handler.ts:256). См. memory feedback-limit-direction.

Two-phase flow для INVOICE_PAYMENT (confirm-handler.ts)

  1. Мерчант: POST /intents с operationType=INVOICE_PAYMENThandler.ts создаёт intent со status=CREATED, без fromAccountName (isInvoiceCreate=true). channel.reserve() записывает qrSignature + expiresAt. Лимиты НЕ проверяются (покупатель ещё неизвестен).
  2. Покупатель сканирует QR → POST /intents/:id/confirm с payerTbAccountId + payerUserId + idempotencyKey (+ опциональный If-Match для optimistic concurrency).
  3. Атомарный UPDATE intent SET status='VALIDATED', fromAccountName=<payer>, userId=<payer>, redeemedByUserId=<payer>, version=version+1 WHERE id=? AND status='CREATED' AND expiresAt > now() [AND version=?]. 0 строк → 409 (ALREADY_PROCESSED / EXPIRED / VERSION_MISMATCH).
  4. calculateFees(PRE|POST) → UPDATE preFeeAmount/postFeeAmount → buildIntentContext() → UPDATE AUTHORIZEDchannel.redeem(ctx) → SETTLED + writeSettlement + notify.

Отмена инвойса (cancel-handler.ts)

Только issuedByUserId (мерчант-инициатор) или actorUserId=0 (admin bypass через service key) могут отменить инвойс. channel.cancel() атомарно переводит CREATED → CANCELED; иначе бросает ConflictError → 409 CANNOT_CANCEL. Для не-two-phase каналов возвращается 400 CANCEL_NOT_SUPPORTED.

5. Жизненный цикл / state machine

Подробно описан в ../architecture/03-intent-saga.md. Краткая Mermaid-схема:

stateDiagram-v2
    [*] --> CREATED
    CREATED --> VALIDATED: fees calculated
    CREATED --> CANCELED: invoice canceled (two-phase)
    CREATED --> EXPIRED: TTL passed (two-phase)
    VALIDATED --> AUTHORIZED: TB pending created
    AUTHORIZED --> SETTLED: TB post_pending (sync or via outbox)
    AUTHORIZED --> FAILED: PSP rejected / void
    AUTHORIZED --> MANUAL_REVIEW: reconciler can't void external pending
    VALIDATED --> FAILED: TB error / limit error
    SETTLED --> [*]
    FAILED --> [*]
    CANCELED --> [*]
    EXPIRED --> [*]

Для двухфазного MERCHANT_INVOICE подробности — в ../architecture/04-two-phase-channels.md.

6. Конфигурация

Env переменные

Имя По умолчанию Используется Назначение
OUTBOX_INTERVAL_MS 1000 outbox-worker.ts:312 (через startOutboxWorker) Период опроса pm.outbox_event
INVOICE_DEFAULT_TTL_SECONDS (см. config.ts) handler.ts:303 TTL по умолчанию для MERCHANT_INVOICE если не задан в metadata.ttlSeconds
NOTIFICATIONS_REDIS_URL null notify.ts:11 Отдельный Redis для notifications stream (если не задан — используется shared client)
REDIS_URL intent-events.ts, handler.ts, notify.ts Основной Redis для intent.{id} pub/sub

Таблицы БД (схема pm.*)

Таблица Где используется Назначение
pm.intent все handlers Основная запись платежа. См. ../reference/database/02-intent.md.
pm.intent_event intent-events.ts Append-only audit log переходов статусов. См. ../reference/database/10-intent-event.md.
pm.outbox_event outbox-worker.ts Очередь отложенных PSP-операций (post_pending, void_pending). См. ../reference/database/09-outbox-event.md.
pm.payment_route router.ts Routing-таблица: (operationType, amount range) → channel. См. ../reference/database/04-payment-route.md.
pm.tx_history settlement-writer.ts Балансовая история (DEBIT/CREDIT/fee) для UI. См. ../reference/database/03-tx-history.md.
pm.tb_account_map outbox-worker.ts, settlement-writer.ts Mapping accountName ↔ TigerBeetle u128 id. См. ../reference/database/06-tb-account-map.md.
pm.service_key handler.ts HMAC-ключи + per-service permissions. См. ../reference/database/11-service-key.md.
pm.psp_tx_map outbox-worker.ts PSP enrichment (display name, fee, settlement date). См. ../reference/database/07-psp-tx-map.md.

7. Тестирование

Все unit-тесты модуля лежат в test/intent/:

test/intent/
├── cancel-handler.test.ts
├── confirm-handler.test.ts
├── errors.test.ts
├── from-account-override.test.ts
├── handler.test.ts
├── intent-events.test.ts
├── nfc-charge.handler.test.ts
├── notify.test.ts
├── operation-registry.test.ts
├── outbox-worker.test.ts
├── quote.test.ts
├── router.test.ts
├── saga-runner.test.ts
├── settlement-writer.test.ts
├── startup-reconciler.test.ts
└── step-registry.test.ts

Интеграционные тесты (полный flow от POST /intents до SETTLED через реальные TB + Redis):

test/integration/
├── invoice-flow.test.ts        # MERCHANT_INVOICE: create → confirm → settled
└── notifications.test.ts       # publishPaymentNotification + Redis stream

Запуск:

npm test -- test/intent             # все юниты модуля
npm test -- test/integration        # интеграция (требует postgres + tigerbeetle + redis)

Подробнее о тестовой инфраструктуре — в ../testing/.

8. Связанные модули

  • channels.md — стратегии исполнения, регистрируются в step-registry.
  • operation-types.mdOperationTypeDefinition (parseBody/resolveAccounts/preValidate/projectHistory).
  • ledger.md — TB-аккаунты, генерация ID, тип tb_account_map.
  • limits.mdcheckLimits() вызывается в шаге 8a handler.ts.
  • rule-engine.mdcalculateFees(PRE)/calculateFees(POST) в шаге 10.
  • psp.md — IPPS-driver, pm.psp_tx_map, путь outbox post_pending для внешних каналов.
  • ../api/intents.md — HTTP-контракты POST /intents, /confirm, /cancel, /quote.
  • ../architecture/03-intent-saga.md — полная state-machine и саговый flow.
  • ../architecture/04-two-phase-channels.md — двухфазный flow для MERCHANT_INVOICE.

9. Заготовки на будущее

Multi-instance OutboxWorker (НЕ применимо сейчас)

Текущая реализация startOutboxWorker() запускает обычный setInterval-loop без распределённой блокировки. Запускать одновременно несколько экземпляров OutboxWorker запрещено (см. CLAUDE.md: «NEVER run multiple OutboxWorker instances simultaneously») — две инстанции возьмут одно и то же outbox_event и попробуют дважды выполнить TB post_pending. TB гарантирует idempotency на уровне transfer ID (статус exists/pending_transfer_already_posted фильтруется в processPostPending), но повторная запись pm.tx_history и повторный publishPaymentNotification приведут к дублированию событий.

Для горизонтального масштабирования OutboxWorker потребуется одно из:

  1. PostgreSQL advisory lock (SELECT pg_try_advisory_lock(...)) с автоматическим failover.
  2. SELECT ... FOR UPDATE SKIP LOCKED при выборке batch'а (как в psp-worker).
  3. Redis-based leader election.

Phase 2B-план — миграция OutboxWorker на тот же паттерн, что используется в psp-worker (PostgreSQL queue + SKIP LOCKED).

Phase 2B: Redis Streams для PSP-адаптеров

Сейчас PSP-логика IPPS живёт в-процессе PM (роль psp-worker, читает pm.psp_tx_map с FOR UPDATE SKIP LOCKED). В Phase 2B:

  • outbox-worker будет писать события не в pm.outbox_event, а в Redis Streams stream.ipps.jobs / stream.qp.jobs.
  • Внешние PSP-адаптеры (отдельные Node.js-процессы) будут consume'ить эти streams и репортить через stream.ipps.results / stream.qp.results.
  • Webhook gateway будет писать в stream.webhook.ipps → новый PM-consumer обновит pm.intent и положит запись в pm.outbox_event для финальной TB-операции.

intent-модуль при этом останется неизменным: единственное изменение — в outbox-worker.ts и в источнике событий PSP-confirm.

Атомарность confirm-handler

Сейчас confirm-handler.ts выполняет UPDATE CREATED → VALIDATED в одной транзакции с проверкой expires_at > now() и If-Match. Дальше — пересчёт комиссий, channel.redeem() и финальный SETTLED идут отдельными транзакциями. В будущем имеет смысл обернуть весь redeem-flow в одну db.transaction(...) для упрощения восстановления при падении посередине (сейчас восстановление через startup-reconciler).