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:
- Operation registry (
src/intent/operation-registry.ts) — выбираетOperationTypeDefinition, который реализуетparseBody,resolveAccounts, опциональноpreValidateиprojectHistory. - Payment-route resolver (
src/intent/handler.ts→ таблицаpm.payment_route) — по паре(operationType, amount)подбираетchannel(INTERNAL_P2P|IPPS_TRANSFER|SERVICE_TRANSFER|ADMIN|MERCHANT_INVOICE). Если маршрута нет —400 NoRouteError. - 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(channelIPPS_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¶
- Создать файл
src/operation-types/<name>.tsс экспортомOperationTypeDefinition(name,parseBody,resolveAccounts, опциональноpreValidate,projectHistory). - Добавить
registerOperationType(...)вsrc/server.tsрядом с остальными. - Если требуется маршрут с лимитами или специфичным channel — добавить запись в
pm.payment_routeчерез миграцию (см.0009_invoice_payment_route.sqlкак образец) и вdrizzle/seed.ts. - Обновить permissions у соответствующего service-key в
drizzle/seed.ts(allowedOperationTypes). - Обновить таблицу в этом документе и пошаговый рецепт
../../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.
См. также¶
02-channels.md— реестр channel-кодов, на которые маршрутизируются operationType.06-service-keys.md— какие service-key вправе инициировать какие operationType.01-dto-contracts.md— Zod-схемы тела запроса для/intents(общийBaseIntentBody+ кастомные расширения, напр.InvoiceCreateBody).../../../../src/intent/operation-registry.ts— runtime-реестр.../../../../drizzle/seed.ts— seed payment_route, fee_rule, limit_rule, service_key.