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

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 стартует без ошибок.