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

Модуль 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.tsregisterOperationType(...)).

Замечание: некоторые 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.ttlSecondsINVOICE_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).
  • recipientUserId vs metadata.recipientUserId. P2P_TRANSFER берёт получателя из тела (body.recipientUserId), а SERVICE_DEPOSIT — из metadata.recipientUserId. Это историческое различие сохраняется ради обратной совместимости старых клиентов; новые operationType должны класть бизнес-параметры в metadata.

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

  • Multi-currency operationTypes (Phase 2C+). Сейчас resolveAccounts подставляет currency в шаблон имени (user.{id}.{currency}). Для FX-операций потребуется отдельный operationType FX_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, чтобы исключить дрейф между файлами и реестром.