Модуль operation-types¶
Реестр операционных типов PM: каждый тип определяет, как парсится тело POST /intents, какие аккаунты участвуют в платеже, какие предварительные проверки выполняются и какие поля метаданных безопасно проецируются в tx_history.
1. Назначение модуля¶
operationType — это центральный дискриминатор каждого платёжного намерения (intent) в PM. Именно по нему сага и rule-engine решают:
- какие аккаунты TigerBeetle списывать и зачислять (
resolveAccounts); - какой канал доставки использовать (
channel— INTERNAL / IPPS / QP / MERCHANT_INVOICE, см. таблицуpm.payment_route); - какие комиссии и лимиты применять (
rule-engineфильтрует правила поoperationType); - какие поля метаданных можно безопасно зафиксировать в
tx_history.attributes(projectHistory).
В отличие от channel (это транспорт, см. channels.md), operationType — это бизнес-намерение инициатора. Один и тот же channel=IPPS обслуживает два разных типа: IPPS_WITHDRAWAL и THAI_QR_PAY, у которых отличается UX и набор fee-правил, но единый исходящий поток в IPPS adapter.
Всего в PM зарегистрировано 11 operationType:
| operationType | Файл | Channel (по умолчанию) | Назначение |
|---|---|---|---|
P2P_TRANSFER |
p2p-transfer.ts |
INTERNAL |
Перевод между двумя user.* кошельками |
IPPS_WITHDRAWAL |
ipps.ts |
IPPS |
Списание на внешний счёт через IPPS |
THAI_QR_PAY |
ipps.ts |
IPPS |
Оплата QR-кода продавца через IPPS |
WITHDRAWAL |
withdrawal.ts |
IPPS |
Обобщённый вывод (требует externalRef) |
QP_TOPUP |
withdrawal.ts |
QP |
Пополнение кошелька через QuickPay (входящий) |
MINIAPP_CHARGE |
miniapp.ts |
INTERNAL |
Списание с клиента в пользу мерчанта мини-приложения |
MINIAPP_CREDIT |
miniapp.ts |
INTERNAL |
Возврат / выплата мерчантом клиенту |
SERVICE_DEPOSIT |
service-transfer.ts |
SERVICE_TRANSFER |
Зачисление с сервисного аккаунта пользователю |
ADMIN_TRANSFER |
admin-transfer.ts |
ADMIN |
Ручная корректировка с явными override-аккаунтами |
INVOICE_PAYMENT |
invoice-payment.ts |
MERCHANT_INVOICE |
Оплата выставленного мерчантом QR-инвойса |
NFC_CHARGE |
nfc-charge.ts |
INTERNAL |
Pull-charge с NFC-карты клиента в пользу мерчанта |
Канонический и всегда актуальный список —
../reference/passport/03-operation-types.md; маппинг operationType→channel —../reference/database/04-payment-route.md.
2. Структура файлов¶
src/operation-types/
├── p2p-transfer.ts # P2P_TRANSFER
├── ipps.ts # IPPS_WITHDRAWAL + THAI_QR_PAY (общий preValidate)
├── miniapp.ts # MINIAPP_CHARGE + MINIAPP_CREDIT
├── withdrawal.ts # WITHDRAWAL + QP_TOPUP
├── service-transfer.ts # SERVICE_DEPOSIT
├── admin-transfer.ts # ADMIN_TRANSFER
├── invoice-payment.ts # INVOICE_PAYMENT
└── nfc-charge.ts # NFC_CHARGE
src/intent/
├── operation-type.ts # интерфейс OperationTypeDefinition + zod-схема BaseIntentBody
└── operation-registry.ts # in-memory Map<string, OperationTypeDefinition>
Каждый файл экспортирует один или несколько объектов OperationTypeDefinition и регистрируется в реестре при бутстрапе приложения (src/app.ts → registerOperationType(...)).
Замечание: некоторые operationType сгруппированы в одном файле, когда у них общая бизнес-логика (
ipps.tsделитpreValidate,miniapp.ts— симметричные направления,withdrawal.ts— общий «вывод/возврат» через системные nostro-аккаунты).
3. Ключевые типы¶
3.1. BaseIntentBody (zod)¶
Базовая схема тела POST /intents, общая для всех operationType (src/intent/operation-type.ts). Каждый operationType может её расширять (.extend(...)) или ужесточать в parseBody:
| Поле | Тип | Описание |
|---|---|---|
idempotencyKey |
UUID | Идемпотентный ключ. Пара (idempotencyKey, serviceId) уникальна. |
operationType |
string | Имя operationType. Должно совпадать с одним из зарегистрированных в registry. |
amount |
int>0 | Сумма в satang (1 THB = 100 satang). |
currency |
3 символа | ISO 4217, default THB. |
metadata |
Record |
Тип-специфичные данные. Структура валидируется в parseBody. |
fromAccountName |
string? | Override источника. Требует permission fromAccountOverride у сервисного ключа. |
toAccountName |
string? | Override назначения. Требует toAccountOverride. |
toTbAccountId |
UUID? | Назначение по TB-UUID. Требует allowToTbAccountId. |
fromName/toName |
string? | Отображаемые имена для истории и чеков. |
comment |
string? | Свободная заметка (до 500 симв.). |
live |
boolean? | Подписка на Redis-канал intent.{id} для live-статусов. |
externalRef |
string? | Обязателен для WITHDRAWAL; запрещён для P2P_TRANSFER, MINIAPP_*, NFC_CHARGE. |
recipientUserId |
int? | Обязателен для P2P_TRANSFER (если нет toTbAccountId); запрещён для WITHDRAWAL, MINIAPP_CHARGE, NFC_CHARGE. |
3.2. OperationTypeDefinition¶
export interface OperationTypeDefinition {
name: string
// 1) Валидация и парсинг тела запроса — синхронная.
// Бросает BadRequestError при нарушении контракта.
parseBody(raw: unknown): ParsedIntentBody
// 2) Резолв пары аккаунтов из userId/currency/metadata.
// null означает: вызвавший обязан передать аккаунт через override
// (fromAccountName / toAccountName), и handler проверит permission.
resolveAccounts(input: ResolveAccountsInput): {
fromAccountName: string | null
toAccountName: string | null
}
// 3) Опциональная асинхронная проверка с доступом к БД.
// Запускается ДО reserve()/saga — может ходить в pm.* (например,
// проверить наличие ippsWalletId у пользователя).
preValidate?(ctx: PreValidateContext): Promise<void>
// 4) PII-gate. Только поля, явно возвращённые этим хуком,
// попадают в tx_history.attributes. Слепое копирование
// metadata запрещено: оно может содержать taxId, nfcTagId
// жертвы и прочие чувствительные значения.
projectHistory?(
metadata: Record<string, unknown>,
direction: 'DEBIT' | 'CREDIT',
): Record<string, unknown>
}
ResolveAccountsInput содержит userId (инициатора), currency, operationType, metadata. PreValidateContext дополнительно даёт db (Drizzle).
4. Основные функции¶
src/intent/operation-registry.ts экспортирует три функции:
| Функция | Назначение |
|---|---|
registerOperationType(def) |
Кладёт определение в Map. Вызывается при бутстрапе. |
getOperationType(name) |
Возвращает определение или бросает BadRequestError("Unknown operationType: ..."). |
clearOperationRegistry() |
Используется только в тестах (между сценариями). |
В коде PM это выглядит так:
// bootstrap (src/app.ts)
registerOperationType(p2pTransfer)
registerOperationType(ippsWithdrawal)
registerOperationType(thaiQrPay)
registerOperationType(withdrawal)
registerOperationType(qpTopup)
registerOperationType(miniappCharge)
registerOperationType(miniappCredit)
registerOperationType(serviceDeposit)
registerOperationType(adminTransfer)
registerOperationType(invoicePaymentOpType)
registerOperationType(nfcCharge)
// handler POST /intents
const def = getOperationType(rawBody.operationType)
const parsed = def.parseBody(rawBody)
await def.preValidate?.({ db, userId, metadata: parsed.metadata, currency: parsed.currency })
const accounts = def.resolveAccounts({ userId, currency, operationType: def.name, metadata: parsed.metadata })
5. Жизненный цикл хуков¶
POST /intents
│
├─► auth/hmac → service-key permissions
│
├─► getOperationType(body.operationType)
│
├─► def.parseBody(rawBody) ◄── zod + custom validation (sync)
│ └─ BadRequestError при нарушении контракта (recipientUserId, externalRef и т.п.)
│
├─► def.preValidate?({ db, ... }) ◄── async, может читать pm.tb_account_map
│ └─ Пример: ippsPreValidate → проверка ippsWalletId, receiverType
│
├─► def.resolveAccounts(...) ◄── вычисление fromAccountName / toAccountName
│ └─ Если возвращено null/null — handler требует override из тела
│
├─► rule-engine: PRE-fees (фильтрация по operationType)
│
├─► reserve() → TigerBeetle pending transfers
│
├─► (channel-specific) IPPS / QP / INTERNAL / MERCHANT_INVOICE flow
│
├─► settle()
│ └─ projectHistory(metadata, direction) вызывается для каждой строки
│ tx_history (DEBIT и CREDIT отдельно); результат → tx_history.attributes
│
└─► rule-engine: POST-fees (если есть)
Подробнее о саге и порядке — ../architecture/saga-state-machine.md и intent.md.
5.1. parseBody — что именно проверяется¶
Хук parseBody отвечает не только за zod-валидацию схемы, но и за «семантические» инварианты, которые невозможно выразить только структурно:
| operationType | Дополнительные проверки в parseBody |
|---|---|
P2P_TRANSFER |
Требует recipientUserId ИЛИ toTbAccountId. Запрещает externalRef. |
IPPS_WITHDRAWAL |
Только базовая схема — основные проверки в preValidate (нужен доступ к БД). |
THAI_QR_PAY |
Аналогично IPPS_WITHDRAWAL. |
WITHDRAWAL |
Требует externalRef (для сверки с PSP). Запрещает recipientUserId. |
QP_TOPUP |
Только базовая схема. Контракт PSP проверяется на webhook-уровне. |
MINIAPP_CHARGE |
Запрещает recipientUserId и externalRef. customerUserId / merchantId читаются из metadata. |
MINIAPP_CREDIT |
Запрещает externalRef. Поля сторон — в metadata. |
SERVICE_DEPOSIT |
Требует metadata.recipientUserId — целое положительное. |
ADMIN_TRANSFER |
Никаких дополнительных проверок — оба аккаунта приходят override-ом, корректность — на стороне правил ключа. |
INVOICE_PAYMENT |
toTbAccountId обязателен (UUID мерчантского счёта). metadata.ttlSeconds ≤ INVOICE_MAX_TTL_SECONDS. Запрещает recipientUserId. |
NFC_CHARGE |
Запрещает recipientUserId, toTbAccountId, externalRef. Если есть metadata.nfcTagId — обязан быть Number.isInteger (индекс в БД). |
5.2. preValidate — где сейчас используется¶
Хук определён только у IPPS_WITHDRAWAL и THAI_QR_PAY (общая функция ippsPreValidate):
async function ippsPreValidate({ db, userId, metadata }) {
const [walletRow] = await db
.select({ ippsWalletId: tbAccountMap.ippsWalletId })
.from(tbAccountMap)
.where(and(eq(tbAccountMap.userId, userId), eq(tbAccountMap.accountType, 'USER_WALLET')))
.limit(1)
if (!walletRow?.ippsWalletId) throw new IppsNotRegisteredError(userId)
const ippsMeta = (metadata as any).ipps ?? {}
if (!VALID_RECEIVER_TYPES.includes(ippsMeta.receiverType)) throw new IppsMetadataInvalidError(...)
if ((ippsMeta.receiverType === 'BANKAC' || ippsMeta.receiverType === 'BILLERID') && !ippsMeta.receiverBank)
throw new IppsMetadataInvalidError(`receiverBank required for receiverType=${ippsMeta.receiverType}`)
}
Инвариант: preValidate падает с 400 BadRequest до резерва — никаких pending-transfers в TigerBeetle, никаких заявок в IPPS adapter не создаётся.
5.3. projectHistory — PII-gate в деталях¶
Только два operationType сейчас определяют этот хук — INVOICE_PAYMENT и NFC_CHARGE. У остальных attributes в tx_history остаётся {}.
INVOICE_PAYMENT пропускает в историю только публично безопасные поля (заметку, идентификатор POS-терминала, время выпуска QR, appScope):
projectHistory(metadata, _dir) {
return {
invoiceNote: metadata.note ?? null,
posTerminalId: metadata.posTerminalId ?? null,
qrIssuedAt: metadata.qrIssuedAt ?? null,
appScope: metadata.appScope ?? 'a',
}
}
NFC_CHARGE асимметричен: nfcTagId принадлежит карте плательщика и потому пишется только в DEBIT-строку, а координаты транзакции и MCC мерчанта — в обе:
projectHistory(metadata, direction) {
const out = {}
if (metadata.lat != null) out.lat = metadata.lat
if (metadata.lon != null) out.lon = metadata.lon
if (metadata.to?.businessCategoryCode != null) out.businessCategoryCode = metadata.to.businessCategoryCode
if (direction === 'DEBIT' && Number.isInteger(metadata.nfcTagId)) out.nfcTagId = metadata.nfcTagId
return out
}
Инвариант PII-gate: «всё, что не вернул projectHistory, не появится в tx_history.attributes». Это исключает случайный leak полей вроде taxId, ФИО получателя, серийных номеров устройств и т.п.
6. Конфигурация: pm.payment_route¶
Маппинг operationType → channel хранится в БД, а не в коде, чтобы DevOps мог менять маршрутизацию без релиза:
| operationType | channel | Комментарий |
|---|---|---|
P2P_TRANSFER |
INTERNAL |
Синхронный settleSync() в PM |
IPPS_WITHDRAWAL |
IPPS |
Через psp-worker + IPPS adapter |
THAI_QR_PAY |
IPPS |
Через IPPS, но другой UX/набор fees |
WITHDRAWAL |
IPPS |
Generic withdraw, требует externalRef |
QP_TOPUP |
QP |
Входящий поток QuickPay |
MINIAPP_CHARGE |
INTERNAL |
Списание клиента → мерчант |
MINIAPP_CREDIT |
INTERNAL |
Обратное направление: мерчант → клиент |
SERVICE_DEPOSIT |
SERVICE_TRANSFER |
С service.* аккаунта пользователю |
ADMIN_TRANSFER |
ADMIN |
Ручная корректировка с двумя overrides |
INVOICE_PAYMENT |
MERCHANT_INVOICE |
Резерв на стороне мерчанта, оплата позже |
NFC_CHARGE |
INTERNAL |
Pull-charge, оба аккаунта явные |
Канонический справочник —
../reference/database/04-payment-route.md.
7. Тестирование¶
В test/operation-types/ хранятся юнит-тесты конкретных определений:
test/operation-types/
├── invoice-payment.test.ts # парсинг тела INVOICE_PAYMENT, TTL, PII-gate
├── ipps.test.ts # IPPS_WITHDRAWAL + THAI_QR_PAY preValidate
├── nfc-charge.test.ts # NFC_CHARGE: nfcTagId-int, override-only, PII
├── p2p-transfer.test.ts # recipientUserId required, externalRef forbidden
└── service-transfer.test.ts # metadata.recipientUserId validation
Что покрывают:
parseBody— позитивные и негативные кейсы (отсутствующие/лишние поля, неверные типы);resolveAccounts— корректность шаблонов имён (user.{id}.{currency},system.nostro.ipps.{currency}и т.п.);preValidate— обращение к БД (через тестовую Drizzle-инстанс) и выброс ожидаемых ошибок;projectHistory— что в результат попадают ровно нужные поля и ничего сверх (PII-инвариант).
Интеграционные тесты operationType целиком (через HTTP POST /intents) лежат в test/intent/ и test/saga/.
8. Связанные модули¶
| Документ | Связь |
|---|---|
| intent.md | Кто и когда вызывает хуки parseBody/resolveAccounts/preValidate |
| channels.md | Что такое channel, чем он отличается от operationType |
| rule-engine.md | Как комиссии и лимиты фильтруются по operationType |
| ledger.md | Как resolveAccounts приземляется в pm.tb_account_map и TB |
../reference/passport/03-operation-types.md |
Канонический список и контракты operationType |
../reference/database/04-payment-route.md |
Источник истины для маппинга operationType → channel |
9. Особенности и инварианты¶
resolveAccounts → null/null⇒ override обязателен. Так работаютADMIN_TRANSFER,INVOICE_PAYMENT(на этапе создания),NFC_CHARGE. Handler сверяет переданныеfromAccountName/toAccountNameс масками прав сервисного ключа (fromAccountOverride,toAccountOverride).projectHistory— это PII-gate. Добавлен в версии 0.2.0 дляNFC_CHARGEи переиспользован вINVOICE_PAYMENT. Слепое копированиеmetadataвtx_history.attributesзапрещено:metadata.taxId,metadata.nfcTagIdжертвы и т.п. не должны попадать в историю получателя. Если у operationType хук не определён — вattributesзаписывается пустой объект.NFC_CHARGE.projectHistoryасимметричен по направлению.nfcTagId(идентификатор карты плательщика) попадает только в DEBIT-строку;lat/lonиbusinessCategoryCode— в обе.INVOICE_PAYMENT.parseBodyуважаетINVOICE_MAX_TTL_SECONDS. Лимит изconfig.INVOICE_MAX_TTL_SECONDSблокирует попытку создать долгоживущий QR.IPPS_WITHDRAWALиTHAI_QR_PAYделят общийpreValidate. Любая правка инвариантов IPPS (валидныеreceiverType, требованиеreceiverBankдляBANKAC/BILLERID) автоматически применяется к обоим.QP_TOPUP— это вход в систему.fromAccountName = system.nostro.ipps.{currency},toAccountName = user.{userId}.{currency}. Деньги «появляются» из nostro-кармана PSP, и инвариантtransit.balance = 0поддерживается двойной проводкой (см.ledger.md).recipientUserIdvsmetadata.recipientUserId.P2P_TRANSFERберёт получателя из тела (body.recipientUserId), аSERVICE_DEPOSIT— изmetadata.recipientUserId. Это историческое различие сохраняется ради обратной совместимости старых клиентов; новые operationType должны класть бизнес-параметры вmetadata.
10. Заготовки на будущее¶
- Multi-currency operationTypes (Phase 2C+). Сейчас
resolveAccountsподставляетcurrencyв шаблон имени (user.{id}.{currency}). Для FX-операций потребуется отдельный operationTypeFX_SWAPс двумя валютами и третьей «свопной» точкой (промежуточныйsystem.fx.*аккаунт). КонтрактOperationTypeDefinitionдля этого расширения, скорее всего, обзаведётся отдельным методомresolveLegs()— возвращающим N>2 ног. - CRYPTO_WITHDRAWAL / CRYPTO_TOPUP. По модели аналогичны
IPPS_*, ноpreValidateдолжен будет проверять KYT-флаги (Travel Rule) — поэтомуPreValidateContextсо временем расширитсяkyt: KytClient. - SUBSCRIPTION_CHARGE. Рекуррентные списания с явным
mandateId. Парсинг тела сильно отличается — потенциальный кандидат на собственное мини-семейство operationType (SUBSCRIPTION_*). - Унификация PII-gate. Сейчас
projectHistoryобязателен только там, где есть чувствительные поля. Возможный refactor: сделать его обязательным для всех новых operationType, а отсутствие — расценивать как «attributes всегда пустой». - Декларативный реестр. Вместо ручных
registerOperationType(...)вapp.tsрассматривается авто-импорт изsrc/operation-types/index.ts, чтобы исключить дрейф между файлами и реестром.