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

Passport — реестр operationType

Канонический реестр всех бизнес-операций Payment Manager. operationType — это первичная единица контракта /intents: он определяет, какой parser применить к телу запроса, как разрешить from/to-аккаунты, какой channel подобрать в payment_route, и какому service-key такая операция вообще разрешена. На 2026-05-29 в реестре ровно 11 типов (см. таблицу ниже).


Назначение

operationType — это enum-подобный код в теле POST /intents. Поле обязательное, кейс-чувствительное, проходит через HMAC-permission-check на этапе hmacPlugin (allowedOperationTypes в service_key.permissions) и далее используется тремя независимыми компонентами PM:

  1. Operation registry (src/intent/operation-registry.ts) — выбирает OperationTypeDefinition, который реализует parseBody, resolveAccounts, опционально preValidate и projectHistory.
  2. Payment-route resolver (src/intent/handler.ts → таблица pm.payment_route) — по паре (operationType, amount) подбирает channel (INTERNAL_P2P | IPPS_TRANSFER | SERVICE_TRANSFER | ADMIN | MERCHANT_INVOICE). Если маршрута нет — 400 NoRouteError.
  3. Rule engine (src/rule-engine/, таблица pm.fee_rule, pm.limit_rule) — фильтрует правила по operationType, чтобы применить корректные комиссии и лимиты.

Реестр закрытый: добавить новый operationType означает изменить код PM (OperationTypeDefinition + регистрация в server.ts) и обновить этот документ. Любой код, отсутствующий в реестре, отвергается с BadRequestError('Unknown operationType: …') — см. operation-registry.ts.

Важно: имена IPPS_DEPOSIT и MINIAPP_PAYMENT не существуют. Если в клиентском коде встречаются — это ошибка интеграции. Депозит через IPPS — это QP_TOPUP (channel IPPS_TRANSFER). Платёж в мини-аппе — это MINIAPP_CHARGE (списание у клиента) или MINIAPP_CREDIT (возврат клиенту).


Анатомия OperationTypeDefinition

Каждый из 11 типов — это объект, реализующий интерфейс OperationTypeDefinition:

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>
}
  • parseBody(raw) — Zod-валидация тела /intents. По умолчанию все 11 типов наследуют BaseIntentBody (idempotencyKey, operationType, amount, currency, metadata, опциональные override-поля и т. п.). Кастомные расширения: InvoiceCreateBody для INVOICE_PAYMENT (требует toTbAccountId как UUID и валидирует metadata.appScope / ttlSeconds и др.). Все запреты на поля (recipientUserId not allowed for X, externalRef required for Y) реализованы здесь — то есть отвергаются до входа в обработчик и до резолва аккаунтов.
  • resolveAccounts(input) — детерминированный резолвер from/to-аккаунтов по контексту (userId, currency, metadata, operationType). Возврат null означает «вызывающий обязан передать fromAccountName / toAccountName явно» — это паттерн для ADMIN_TRANSFER, NFC_CHARGE и INVOICE_PAYMENT. Override'ы валидируются масками service_key.permissions (fromAccountOverride.allowedTypes, namePattern).
  • preValidate(ctx) — опциональный pre-flight check, выполняется до бронирования средств. Используется в IPPS_WITHDRAWAL / THAI_QR_PAY для проверки регистрации в IPPS (ippsWalletId в tb_account_map) и валидации metadata.ipps.receiverType.
  • projectHistory(metadata, direction) — PII gate. Реализован для NFC_CHARGE и INVOICE_PAYMENT. Любые поля metadata, которые должны попасть в tx_history.attributes, должны быть явно перечислены. Слепой Object.assign запрещён — иначе в историю утекут taxId, passportNo и т. п.

ResolveAccountsInput и PreValidateContext определены в src/intent/operation-type.ts. Подробнее о Zod-схеме BaseIntentBody см. 01-dto-contracts.md.


Сводная таблица — 11 operationType

Код Channel (по payment_route) Кто может инициировать (service key) Файл реализации
P2P_TRANSFER не в seed — резолвится как INTERNAL_P2P дефолтным fallback'ом auth-center src/operation-types/p2p-transfer.ts
IPPS_WITHDRAWAL IPPS_TRANSFER (seed: 100..200 000 00 satang) auth-center src/operation-types/ipps.ts
THAI_QR_PAY IPPS_TRANSFER (seed: 100..200 000 00 satang) auth-center src/operation-types/ipps.ts
WITHDRAWAL не в seed — endpoint-специфичный fallback auth-center src/operation-types/withdrawal.ts
QP_TOPUP не в seed — endpoint-специфичный fallback auth-center src/operation-types/withdrawal.ts
MINIAPP_CHARGE не в seed — endpoint-специфичный fallback auth-center src/operation-types/miniapp.ts
MINIAPP_CREDIT не в seed — endpoint-специфичный fallback auth-center src/operation-types/miniapp.ts
SERVICE_DEPOSIT SERVICE_TRANSFER (seed: 1..9 999 999 99 satang) exchange-webhook (опционально, при наличии секрета) src/operation-types/service-transfer.ts
ADMIN_TRANSFER ADMIN (seed: 1..9 999 999 99 satang) admin-panel src/operation-types/admin-transfer.ts
NFC_CHARGE INTERNAL_P2P (seed: 0..int64.max satang, лимиты в limit_rule) auth-center-merchantединственный src/operation-types/nfc-charge.ts
INVOICE_PAYMENT MERCHANT_INVOICE (миграция 0009: 1..100 000 000 satang) auth-center src/operation-types/invoice-payment.ts

Open question / тех. долг: для пяти операционных кодов (P2P_TRANSFER, WITHDRAWAL, QP_TOPUP, MINIAPP_CHARGE, MINIAPP_CREDIT) в drizzle/seed.ts отсутствуют записи в pm.payment_route. Это фактическое состояние seed'а на момент документа — фактический channel определяется fallback-логикой в обработчике /intents. Если в будущем для этих типов появится физический маршрут с лимитами — он должен быть добавлен и в seed, и в эту таблицу.


Описание каждой операции

1. P2P_TRANSFER — peer-to-peer перевод между пользователями

Бизнес-смысл. Прямой перевод средств между двумя пользователями кошелька (user → user, либо user → merchant/agent при P2P в мини-аппе). Никаких внешних PSP, settle'ит синхронно через TigerBeetle.

Отправитель / получатель. from = user.{userId}.{currency} (или override на merchant.* / agent.*); to = user.{recipientUserId}.{currency} (или toTbAccountId напрямую). recipientUserId либо toTbAccountId — обязательны; иначе BadRequestError.

Особенности. - externalRef запрещён (это внутренняя операция, нет внешнего идентификатора). - На P2P действует базовая комиссия 1.5% (system.revenue.THB, PRE-timing). VIP-теги дают сниженную комиссию 0.5%; тег fee_exempt отключает оба правила. См. seed fee_rule. - В seed нет payment_route — фактический channel = INTERNAL_P2P через дефолтный fallback handler'а.


2. IPPS_WITHDRAWAL — вывод средств через IPPS (Bank of Thailand)

Бизнес-смысл. Списание с кошелька пользователя на внешний банковский счёт / e-wallet / billerID через IPPS-шлюз ЦБ Таиланда. Двух-фазная: PM делает pending-transfer, IPPS adapter забирает job из PostgreSQL queue (psp_tx_map) и подтверждает в саге.

Отправитель / получатель. from = user.{userId}.{currency}; to = system.nostro.ipps.{currency} (nostro-счёт IPPS). Получатель в реальном мире указан в metadata.ipps (receiverType, receiverBank, и т. п.).

Особенности. - preValidate: пользователь должен быть зарегистрирован в IPPS — в pm.tb_account_map должен присутствовать ippsWalletId. Иначе IppsNotRegisteredError. - metadata.ipps.receiverType обязателен и должен быть одним из MSISDN, NATID, EWALLETID, BANKAC, BILLERID. Для BANKAC / BILLERID обязателен receiverBank. - Route в seed: IPPS_TRANSFER, диапазон 1 THB … 200 000 THB.


3. THAI_QR_PAY — оплата по тайскому QR-коду (PromptPay)

Бизнес-смысл. Оплата товаров / услуг по сканированному QR-коду (PromptPay). С точки зрения PM — это та же операция, что IPPS_WITHDRAWAL: те же аккаунты, тот же channel, тот же preValidate. Разница только в источнике инициации (Flutter UI: сканер QR против формы вывода) и в метаданных (parsed QR payload).

Отправитель / получатель. Идентичны IPPS_WITHDRAWAL: user.{userId}.{currency}system.nostro.ipps.{currency}.

Особенности. - Использует тот же ippsPreValidate, что и IPPS_WITHDRAWAL. - Route в seed: IPPS_TRANSFER, диапазон 1 THB … 200 000 THB. - Отдельный код нужен для аналитики (распределение комиссий / тарифной сетки), для метрик (доля QR-оплат) и для будущей развязки логики (например, отдельный rule-engine-фильтр).


4. WITHDRAWAL — общий вывод средств (legacy / широкий вывод)

Бизнес-смысл. Вывод средств с явным externalRef (внешний идентификатор транзакции от инициатора — например, ID вывода на стороне Auth Center). Старый канал вывода до выделения отдельного IPPS_WITHDRAWAL.

Отправитель / получатель. from = user.{userId}.{currency}; to = system.nostro.ipps.{currency}.

Особенности. - externalRef обязателен (отличие от IPPS_WITHDRAWAL, где он опциональный). - recipientUserId запрещён. - В seed payment_route нет — фактический channel определяется обработчиком на лету.


5. QP_TOPUP — пополнение кошелька через QP (Quick Pay)

Бизнес-смысл. Зачисление средств на кошелёк пользователя из внешнего источника (QP / банковский перевод в кошелёк). Обратное направление к IPPS_WITHDRAWAL.

Отправитель / получатель. from = system.nostro.ipps.{currency}; to = user.{userId}.{currency}.

Особенности. - Никаких обязательных метаданных или preValidate — операция инициируется внутренним webhook'ом (после подтверждения зачисления на nostro-счёт). - В seed payment_route нет.


6. MINIAPP_CHARGE — списание с пользователя в пользу мини-аппа

Бизнес-смысл. Покупка / списание в мини-аппе: со счёта клиента (customerUserId) на счёт мерчанта (merchantId). Является обычным внутренним переводом, но требует контекста мини-аппа (для атрибуции и комиссии).

Отправитель / получатель. from = user.{customerUserId ?? userId}.{currency}; to = user.{merchantId ?? userId}.{currency}.

Особенности. - recipientUserId и externalRef запрещены (контекст передаётся через metadata.customerUserId / metadata.merchantId). - Базовая комиссия 2% (POST-timing) в system.revenue.THB. Тег fee_exempt отключает комиссию. - В seed payment_route нет. - TODO в seed: merchant revenue-sharing rules требуют B4 Rule Engine context extension (динамический merchantId в expression).


7. MINIAPP_CREDIT — зачисление пользователю от мини-аппа (возврат / выплата)

Бизнес-смысл. Обратная операция к MINIAPP_CHARGE: возврат средств клиенту, кэшбек, выплата выигрыша, etc.

Отправитель / получатель. from = user.{merchantId ?? userId}.{currency}; to = user.{customerUserId ?? userId}.{currency}.

Особенности. - externalRef запрещён. - recipientUserId допустим (см. parser — нет явного запрета). - В seed payment_route нет.


8. SERVICE_DEPOSIT — зачисление со служебного счёта на счёт пользователя

Бизнес-смысл. Сервисное зачисление: например, начисление от криптообменника (exchange-webhook), внутренние корректировки от служебных счётов (service.{serviceId}.{currency}).

Отправитель / получатель. from = service.{userId}.{currency} (где userId идентифицирует service-account); to = user.{recipientUserId}.{currency}.

Особенности. - metadata.recipientUserId обязателен и должен быть положительным integer'ом. - Channel в seed — SERVICE_TRANSFER, диапазон 1 satang … 9 999 999 99 satang (~99M THB). - В seed условно зашит service-key exchange-webhook — добавляется только если в SERVICE_SECRETS присутствует соответствующий секрет; fromAccountOverride ограничен service.9001.*.


9. ADMIN_TRANSFER — административный перевод

Бизнес-смысл. Ручная операция бухгалтера / администратора между любыми системными или клиентскими счетами. Используется для корректировок, ручных компенсаций, миграционных переводов, etc.

Отправитель / получатель. Не резолвится автоматически. resolveAccounts возвращает { null, null } — оба аккаунта должны быть переданы явно в теле запроса (fromAccountName, toAccountName) и валидируются масками fromAccountOverride / toAccountOverride у service-key admin-panel.

Особенности. - Channel в seed — ADMIN, диапазон 1 satang … 9 999 999 99 satang. - Маски admin-panel разрешают любые типы аккаунтов из множества EQUITY, NOSTRO, REVENUE, USER_WALLET, TRANSIT, SERVICE_ACCOUNT, MERCHANT_WALLET, MERCHANT_SETTLEMENT, AGENT_WALLET, AGENT_SETTLEMENT; namePattern: ^(system\.|user\.|merchant\.|agent\.|service\.).


10. NFC_CHARGE — pull-charge мерчантом через NFC-метку

Бизнес-смысл. Списание у клиента (USER_WALLET / AGENT_WALLET) в пользу мерчанта (MERCHANT_WALLET) при тапе по NFC-метке POS-терминала. Pull-сценарий: операция инициируется со стороны мерчанта.

Отправитель / получатель. Не резолвится автоматическиresolveAccounts возвращает { null, null }. Оба аккаунта обязаны быть переданы явно (fromAccountName, toAccountName); их типы валидируются масками service-key auth-center-merchant: - fromAccountOverride: USER_WALLET | AGENT_WALLET, паттерн ^(user\.|agent\.). - toAccountOverride: MERCHANT_WALLET, паттерн ^merchant\..

Особенности. - Единственный service-key, которому разрешён NFC_CHARGE, — auth-center-merchant. Никакой другой ключ не имеет этой операции в allowedOperationTypes. См. seed (auth-center-merchant.allowedOperationTypes = ['NFC_CHARGE']). - recipientUserId, toTbAccountId, externalRef запрещены. - metadata.nfcTagId опционален, но если присутствует — обязан быть integer (иначе 400). Это связано с индексом tx_history_nfc_tag_idx на (attributes->>'nfcTagId')::int (миграция 0006_rapid_millenium_guard.sql). - Channel в seed — INTERNAL_P2P, диапазон 0 … int64.max satang. Per-tap и daily ceiling'и enforced'ятся через limit_rule: - nfc_per_tap: direction=DEBIT, window=PER_TX, amountLimit=30 000 satang (300 THB). - nfc_daily: direction=DEBIT, window=DAILY, amountLimit=150 000 satang (1 500 THB). - Downstream contract: direction=DEBIT означает, что Auth Center обязан слать в X-User-Id идентификатор клиента, а не мерчанта. Иначе limitDirection резолвится в CREDIT и оба NFC-лимита тихо обходятся. См. drizzle/seed.ts и memory feedback-limit-direction. - projectHistory: на DEBIT-строке tx_history записывает nfcTagId; на любой строке — lat, lon, businessCategoryCode (если есть в metadata). Всё остальное в tx_history.attributes не попадает (PII gate).


11. INVOICE_PAYMENT — оплата мерчантского инвойса

Бизнес-смысл. Клиент сканирует QR / открывает ссылку, созданную мерчантом, и подтверждает оплату инвойса. Инвойс создаётся мерчантом заранее (тело включает toTbAccountId мерчанта); оплата происходит позже, когда клиент confirm'ит.

Отправитель / получатель. Не резолвится при созданииresolveAccounts возвращает { null, null }. Реальные аккаунты определяются в reserve() в момент confirm'а: from берётся из контекста плательщика, to — из toTbAccountId, переданного при создании инвойса.

Особенности. - toTbAccountId обязателен и должен быть валидным UUID (Zod-схема InvoiceCreateBody). - recipientUserId запрещён. - metadata.ttlSeconds опционален; не должен превышать INVOICE_MAX_TTL_SECONDS (config, в секундах). - metadata.appScope{ 'w', 'c', 'a' } (default 'a'); note ≤ 500 символов; posTerminalId ≤ 100 символов; qrIssuedAt — ISO 8601 datetime. - Channel — MERCHANT_INVOICE (миграция 0009_invoice_payment_route.sql), диапазон 1 satang … 100 000 000 satang (~1M THB). - projectHistory: в tx_history идут только invoiceNote, posTerminalId, qrIssuedAt, appScope. PII (например, имя плательщика) не сохраняется.


Регистрация

Регистрация всех 11 типов происходит до первого HTTP-запроса в src/server.ts (строки ~123-148). Используется registerOperationType() из src/intent/operation-registry.ts:

const { registerOperationType }        = await import('./intent/operation-registry.js')
const { p2pTransfer }                  = await import('./operation-types/p2p-transfer.js')
const { ippsWithdrawal, thaiQrPay }    = await import('./operation-types/ipps.js')
const { miniappCharge, miniappCredit } = await import('./operation-types/miniapp.js')
const { withdrawal, qpTopup }          = await import('./operation-types/withdrawal.js')

registerOperationType(p2pTransfer)
registerOperationType(ippsWithdrawal)
registerOperationType(thaiQrPay)
registerOperationType(miniappCharge)
registerOperationType(miniappCredit)
registerOperationType(withdrawal)
registerOperationType(qpTopup)

const { serviceDeposit } = await import('./operation-types/service-transfer.js')
registerOperationType(serviceDeposit)

const { adminTransfer } = await import('./operation-types/admin-transfer.js')
registerOperationType(adminTransfer)

const { nfcCharge } = await import('./operation-types/nfc-charge.js')
registerOperationType(nfcCharge)

const { invoicePaymentOpType } = await import('./operation-types/invoice-payment.js')
registerOperationType(invoicePaymentOpType)

Реестр — обычный Map<string, OperationTypeDefinition> в module scope; в тестах используется clearOperationRegistry(), чтобы переинициализировать его между прогонами. Любой operationType, не зарегистрированный к моменту входящего запроса, отвергается с BadRequestError('Unknown operationType: …').

Добавление нового operationType

  1. Создать файл src/operation-types/<name>.ts с экспортом OperationTypeDefinition (name, parseBody, resolveAccounts, опционально preValidate, projectHistory).
  2. Добавить registerOperationType(...) в src/server.ts рядом с остальными.
  3. Если требуется маршрут с лимитами или специфичным channel — добавить запись в pm.payment_route через миграцию (см. 0009_invoice_payment_route.sql как образец) и в drizzle/seed.ts.
  4. Обновить permissions у соответствующего service-key в drizzle/seed.ts (allowedOperationTypes).
  5. Обновить таблицу в этом документе и пошаговый рецепт ../../cookbook/add-operation-type.md.

Сводка по channel-кодам (cross-reference)

Каждому operationType соответствует ровно один channel (на сумму) — либо через явную запись в pm.payment_route (см. seed / миграции), либо через fallback handler'а /intents. Полный реестр channel-кодов — в 02-channels.md; здесь — только обратное отображение channel → operationType:

Channel operationType(ы), которые на него маршрутизируются
INTERNAL_P2P P2P_TRANSFER (через fallback), NFC_CHARGE (через seed)
IPPS_TRANSFER IPPS_WITHDRAWAL, THAI_QR_PAY (оба через seed). WITHDRAWAL и QP_TOPUP — через fallback (route не сидится)
SERVICE_TRANSFER SERVICE_DEPOSIT (через seed)
ADMIN ADMIN_TRANSFER (через seed)
MERCHANT_INVOICE INVOICE_PAYMENT (через миграцию 0009)

Терминологическая ловушка. SERVICE_TRANSFER — это channel, а не operationType. Это значение колонки payment_route.channel, для которого зарегистрирован тип SERVICE_DEPOSIT. Аналогично INTERNAL_P2P — channel, который используется и для P2P_TRANSFER, и для NFC_CHARGE. Не путать channel'ы с operationType.


Матрица «service-key → operationType»

Cross-reference для тех, кто проектирует интеграцию (полный реестр service-key — в 06-service-keys.md). Все значения — из drizzle/seed.ts.

service-key Разрешённые operationType
auth-center P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY, WITHDRAWAL, QP_TOPUP, MINIAPP_CHARGE, MINIAPP_CREDIT, INVOICE_PAYMENT
nginx-gateway нет — read-only (balance + history)
admin-tool нет (но включён forceResolve)
admin-panel ADMIN_TRANSFER
auth-center-merchant NFC_CHARGE (и только она)
exchange-webhook SERVICE_DEPOSIT (только если в SERVICE_SECRETS есть секрет; иначе ключ не создаётся)

Любой запрос POST /intents с operationType, не входящим в allowedOperationTypes ключа, отвергается на этапе HMAC-аутентификации с 403 Forbidden. Если operationType есть в реестре кода (operation-registry.ts), но не разрешён ключу — это ошибка интеграции, не ошибка PM.


См. также