Cookbook — Добавить новый operationType¶
Summary. Пошаговый рецепт добавления нового платёжного
operationTypeв Payment Manager: от файла-определения до passport, fee/limit/policy правил и CHANGELOG.
Что делаем¶
Добавляем в PM новый тип платёжной операции (operationType), который умеет:
- разбирать тело
POST /intents(parseBody); - резолвить пары аккаунтов
from/toпо контексту вызова (resolveAccounts); - опционально выполнять предвалидацию по БД (
preValidate); - опционально проектировать поля из
metadataвtx_history.attributes(projectHistory, PII-gate).
Под капотом тип регистрируется в operation-registry и становится доступен через getOperationType(name) для intent-pipeline.
Когда это нужно¶
Пример сценария: запускаем новую категорию платежей, например MERCHANT_REFUND — возврат части суммы покупателю с merchant-аккаунта. Логика отличается от существующих P2P_TRANSFER / INVOICE_PAYMENT:
- источник средств — merchant TB-аккаунт (резолвится по
metadata.merchantId); - получатель — wallet покупателя (резолвится по
metadata.customerUserId); - требуется проверка, что исходный платёж принадлежит этому merchant (
preValidate); - в
tx_historyнуженoriginalIntentId, но не реквизиты карты (PII).
В таком случае создаём отдельный operationType, а не «дописываем условие» в чужой код.
Предусловия¶
- Прочитан PASSPORT.md — список существующих operationType и их инварианты.
- Прочитаны
../reference/passport/03-operation-types.mdи../modules/operation-types.md. - Понятно, какие аккаунты участвуют (см. Account resolution pattern) — PM никогда не «угадывает» аккаунт по роли пользователя, имена приходят либо из реестра, либо из тела запроса.
- Понятна сторона лимитов (DEBIT/CREDIT относительно конкретного
userId) — см. limit-rules.md. - Есть свободное имя operationType в верхнем регистре snake/upper-snake — например
MERCHANT_REFUND.
Запрещённые имена для копирования:
IPPS_DEPOSIT,MINIAPP_PAYMENT,SERVICE_TRANSFER— таких operationType в PM нет.
Шаги¶
1. Создать файл operation-type¶
Файл: src/operation-types/<kebab-name>.ts (например src/operation-types/merchant-refund.ts).
Берём за образец src/operation-types/invoice-payment.ts и заполняем 2-4 хука:
// src/operation-types/merchant-refund.ts
import { z } from 'zod'
import { BadRequestError } from '../shared/errors.js'
import {
BaseIntentBody,
type OperationTypeDefinition,
type ResolveAccountsInput,
type PreValidateContext,
} from '../intent/operation-type.js'
// 1.1. Описываем metadata-схему (всё, что приходит в body.metadata).
const RefundMetadata = z.object({
merchantId: z.string().uuid(),
customerUserId: z.number().int().positive(),
originalIntentId: z.string().uuid(),
reason: z.string().max(500).optional(),
}).passthrough()
// 1.2. Расширяем BaseIntentBody — добавляем литерал operationType.
const MerchantRefundBody = BaseIntentBody.extend({
operationType: z.literal('MERCHANT_REFUND'),
metadata: RefundMetadata,
})
export const merchantRefundOpType: OperationTypeDefinition = {
name: 'MERCHANT_REFUND',
// 1.3. parseBody — Zod-валидация + дополнительные инварианты.
parseBody(raw: unknown) {
const body = MerchantRefundBody.parse(raw)
if ((raw as { recipientUserId?: unknown }).recipientUserId != null) {
throw new BadRequestError('recipientUserId not allowed for MERCHANT_REFUND')
}
return body
},
// 1.4. resolveAccounts — имена аккаунтов в нотации PM.
// Если PM не может вычислить аккаунт сам — возвращаем null
// и требуем from/toAccountName override в теле (с permission на service-key).
resolveAccounts(input: ResolveAccountsInput) {
const md = input.metadata as { merchantId: string; customerUserId: number }
return {
fromAccountName: `merchant:${md.merchantId}:THB`,
toAccountName: `wallet:${md.customerUserId}:THB`,
}
},
// 1.5. preValidate — опционально: запросы в БД до резервации.
async preValidate(ctx: PreValidateContext) {
// Пример: проверить, что originalIntentId принадлежит этому merchant.
// Бросаем BadRequestError / NotFoundError при несовпадении.
},
// 1.6. projectHistory — PII-gate: что из metadata уйдёт в tx_history.attributes.
projectHistory(metadata, _direction) {
return {
merchantId: metadata.merchantId ?? null,
originalIntentId: metadata.originalIntentId ?? null,
reason: metadata.reason ?? null,
// НЕ копируем PII (taxId, телефон, карта) — даже если они вдруг в metadata.
}
},
}
Если operationType не нуждается в каком-то хуке (
preValidate,projectHistory) — просто не объявляй его. Они помечены?вOperationTypeDefinition.
2. Зарегистрировать в operation-registry¶
Регистрация выполняется через функцию registerOperationType() из src/intent/operation-registry.ts, а вызывается на bootstrap в src/server.ts (по аналогии с invoicePaymentOpType):
// src/server.ts (фрагмент bootstrap-функции)
const { registerOperationType } = await import('./intent/operation-registry.js')
const { merchantRefundOpType } = await import('./operation-types/merchant-refund.js')
// ...
registerOperationType(merchantRefundOpType)
После этого getOperationType('MERCHANT_REFUND') будет находить определение, а intent-pipeline (POST /intents) — корректно валидировать тело.
3. (Если нужна) Создать payment_route¶
Если новый operationType должен ходить через конкретный канал (INTERNAL, IPPS, THAI_QR, ADMIN и т.д.) и набор fee-rules, нужен payment_route. Канал называется ADMIN, а не ADMIN_TRANSFER.
Создай миграцию: drizzle/migrations/NNNN_merchant_refund_payment_route.sql:
-- pm.payment_route: новый маршрут для MERCHANT_REFUND по каналу INTERNAL
INSERT INTO pm.payment_route (operation_type, channel, enabled, priority)
VALUES ('MERCHANT_REFUND', 'INTERNAL', TRUE, 100);
Применить: npx drizzle-kit migrate. SQL вручную в PG-консоли не запускаем.
4. Описать fee_rule (если нужен)¶
Если operationType взимает комиссию — добавь правило в pm.fee_rule. Подробности и шаблон SQL — см. ./write-fee-rule.md.
5. Описать limit_rule (если нужен)¶
Если operationType должен учитываться в дневных/месячных лимитах пользователя, добавь правило в pm.limit_rule. Внимание на сторону (DEBIT для отправителя, CREDIT для получателя) — направление определяется по tb_account_map.userId, а не по префиксу имени аккаунта. Подробности — ./write-limit-rule.md.
6. Описать auth_policy (если нужна)¶
Если operationType требует step-up auth (PIN / биометрия / 3DS) при определённых условиях, добавь запись в pm.auth_policy. Подробности и шаблон условий — ./write-auth-policy.md.
7. Добавить service_key permission (если новый ключ)¶
Если operationType вызывается новым сервисом (новый X-Service-Id), или требует флагов вроде fromAccountOverride / toAccountOverride / allowToTbAccountId, нужно создать/обновить service_key. Подробности — ./add-service-key.md.
8. Написать unit-тесты¶
Файл: test/operation-types/<kebab-name>.test.ts (по образцу test/operation-types/invoice-payment.test.ts).
Минимальный набор кейсов:
import { describe, it, expect } from 'vitest'
import { merchantRefundOpType } from '../../src/operation-types/merchant-refund.js'
describe('merchantRefundOpType.parseBody', () => {
const valid = {
operationType: 'MERCHANT_REFUND',
idempotencyKey: '550e8400-e29b-41d4-a716-446655440001',
amount: 15000,
currency: 'THB',
metadata: {
merchantId: '550e8400-e29b-41d4-a716-446655440002',
customerUserId: 42,
originalIntentId: '550e8400-e29b-41d4-a716-446655440003',
},
}
it('accepts valid', () => {
expect(merchantRefundOpType.parseBody(valid).operationType).toBe('MERCHANT_REFUND')
})
it('rejects recipientUserId', () => {
expect(() => merchantRefundOpType.parseBody({ ...valid, recipientUserId: 5 }))
.toThrow(/recipientUserId not allowed/)
})
it('rejects missing merchantId', () => {
const { metadata, ...rest } = valid
expect(() => merchantRefundOpType.parseBody({
...rest,
metadata: { customerUserId: 42, originalIntentId: valid.metadata.originalIntentId },
})).toThrow()
})
})
describe('resolveAccounts', () => {
it('returns merchant/wallet pair', () => {
const r = merchantRefundOpType.resolveAccounts({
userId: 0, currency: 'THB', operationType: 'MERCHANT_REFUND',
metadata: { merchantId: 'm1', customerUserId: 42 },
})
expect(r.fromAccountName).toBe('merchant:m1:THB')
expect(r.toAccountName).toBe('wallet:42:THB')
})
})
describe('projectHistory', () => {
it('drops PII', () => {
const out = merchantRefundOpType.projectHistory!(
{ merchantId: 'm1', originalIntentId: 'i1', reason: 'duplicate', taxId: 'SECRET' },
'DEBIT',
)
expect(out).toMatchObject({ merchantId: 'm1', originalIntentId: 'i1', reason: 'duplicate' })
expect(out).not.toHaveProperty('taxId') // PII-gate сработал
})
})
9. Обновить passport¶
Файл: ../reference/passport/03-operation-types.md. Добавь запись с инвариантами: какие поля обязательны, какие запрещены, какие каналы поддерживаются, как считаются комиссии.
10. Обновить modules-документ¶
Файл: ../modules/operation-types.md. Туда уходит описание контракта OperationTypeDefinition и таблица существующих типов.
11. Обновить business-документ¶
Файл: ../../business/04-operation-types.md. Описание для не-разработчика: бизнес-сценарий, кто инициирует, что происходит с деньгами.
12. Обновить CHANGELOG¶
Файл: CHANGELOG.md (корень проекта). Кратко: что добавлено и почему.
### Added
- operationType `MERCHANT_REFUND` — возврат части суммы покупателю с merchant-аккаунта (см. cookbook/add-operation-type.md).
Проверка¶
# 1. Unit-тесты на новый operationType.
npm test -- test/operation-types/merchant-refund.test.ts
# 2. Полный прогон, чтобы поймать конфликты регистрации.
npm test
# 3. Применить миграцию payment_route (если создавалась).
npx drizzle-kit migrate
# 4. Локально стартануть PM и проверить, что bootstrap не падает.
npm run dev
# 5. End-to-end: вызвать POST /intents с подписанным HMAC.
curl -X POST http://localhost:8080/intents \
-H "X-Service-Id: <service-key>" \
-H "X-Timestamp: $(date +%s)" \
-H "X-Signature: <hmac>" \
-H "Content-Type: application/json" \
-d '{
"idempotencyKey": "550e8400-e29b-41d4-a716-446655440001",
"operationType": "MERCHANT_REFUND",
"amount": 15000,
"currency": "THB",
"metadata": {
"merchantId": "550e8400-e29b-41d4-a716-446655440002",
"customerUserId": 42,
"originalIntentId": "550e8400-e29b-41d4-a716-446655440003"
}
}'
Ожидаем 201 Created с intentId и status=PENDING (или SETTLED для синхронного INTERNAL).
Что не забыть — чек-лист¶
- Файл
src/operation-types/<name>.tsсоздан, экспорт<name>OpTypeсоответствуетOperationTypeDefinition. - Вызов
registerOperationType(<name>OpType)добавлен вsrc/server.ts(рядом с остальными типами). -
parseBodyвалидирует metadata Zod-схемой и кидаетBadRequestErrorдля запрещённых полей. -
resolveAccountsвозвращает либо реальные имена, либоnull/null(если override обязателен) — но никогда не «угадывает» аккаунт по роли пользователя. -
projectHistoryдействует как PII-gate: возвращает только перечисленные явно поля; PII (taxId, телефон, карта) не копируется. - Passport (
../reference/passport/03-operation-types.md) обновлён — operationType описан с инвариантами. - payment_route (если нужен) добавлен миграцией
drizzle-kit; канал —INTERNAL/IPPS/THAI_QR/ADMIN, но неADMIN_TRANSFER. - fee_rule (
./write-fee-rule.md) написано или явно отмечено как «комиссия не взимается». - limit_rule (
./write-limit-rule.md) написано — учтена сторона DEBIT/CREDIT поtb_account_map.userId. - auth_policy (
./write-auth-policy.md) написана (если требуется step-up auth). - service_key (
./add-service-key.md) создан / обновлён с нужными permission, если operationType вызывается новым ключом или требует override-флагов. - Unit-тесты
test/operation-types/<name>.test.tsпокрывают parseBody (happy + reject), resolveAccounts, projectHistory. - Документы
../reference/passport/03-operation-types.md,../modules/operation-types.md,../../business/04-operation-types.mdсинхронизированы. -
CHANGELOG.mdобновлён. -
npm testзелёный,npx drizzle-kit migrateотработал,npm run devстартует без ошибок.