Модуль 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-idflutter-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 каналы идут по «классической» саге с двумя шагами (authorize → settle/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)¶
- Мерчант:
POST /intentsсoperationType=INVOICE_PAYMENT→handler.tsсоздаёт intent соstatus=CREATED, безfromAccountName(isInvoiceCreate=true).channel.reserve()записываетqrSignature+expiresAt. Лимиты НЕ проверяются (покупатель ещё неизвестен). - Покупатель сканирует QR →
POST /intents/:id/confirmсpayerTbAccountId+payerUserId+idempotencyKey(+ опциональныйIf-Matchдля optimistic concurrency). - Атомарный
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). calculateFees(PRE|POST)→ UPDATE preFeeAmount/postFeeAmount →buildIntentContext()→ UPDATEAUTHORIZED→channel.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.md—OperationTypeDefinition(parseBody/resolveAccounts/preValidate/projectHistory).ledger.md— TB-аккаунты, генерация ID, типtb_account_map.limits.md—checkLimits()вызывается в шаге 8ahandler.ts.rule-engine.md—calculateFees(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 потребуется одно из:
- PostgreSQL advisory lock (
SELECT pg_try_advisory_lock(...)) с автоматическим failover. SELECT ... FOR UPDATE SKIP LOCKEDпри выборке batch'а (как вpsp-worker).- 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 Streamsstream.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).