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

Cookbook: написать fee_rule

Практическое руководство: как добавить новое правило расчёта комиссии в pm.fee_rule — на примере P2P_TRANSFER 1.5 %.

Важно. fee_rule — это правила движка комиссий (src/rule-engine/), а не лимиты (limit_rule) и не маршруты (payment_route). Сюда попадают только формулы вида «сколько и на какой equity-счёт перечислить» — limit/route создаются отдельно.

1. Контекст: что такое fee_rule

pm.fee_rule — справочник формул комиссии. На каждый intent движок (calculateFees() в src/rule-engine/fee-calculator.ts) выбирает все активные правила, подходящие по operation_type, tags_include/tags_exclude и timing, вычисляет каждое выражение в sandbox node:vm и складывает результаты в общие splits.

Схема (src/shared/schema.ts, секция B4):

pm.fee_rule(
  id             serial PK,
  name           varchar(100),
  operation_type varchar(50),
  expression     text,                 -- JS-выражение, возвращает массив { account, amount }
  timing         char(4)  default 'PRE',  -- 'PRE' | 'POST' (check-constraint)
  tags_include   text[]   default '{}',
  tags_exclude   text[]   default '{}',
  priority       integer  default 0,
  active         boolean  default true,
  created_at     timestamptz default now()
)

Unique-ключа нет — несколько правил по одному operation_type нормальны: например, базовое и VIP-скидка.

2. PRE vs POST — когда какой timing

timing определяет в какой момент саги удерживается комиссия:

  • PRE — рассчитывается до основного перевода. Сумма комиссии прибавляется к amount и списывается с отправителя сверх него (отправитель платит). Используется, когда комиссия видна пользователю заранее, как часть котировки POST /intents/quote. Типично для P2P_TRANSFER, IPPS_WITHDRAWAL, THAI_QR_PAY.
  • POST — рассчитывается после основного перевода. Сумма комиссии удерживается из переведённой суммы, то есть получатель (мерчант) получает amount - fee. Используется, когда комиссия за факт получения — например, MINIAPP_CHARGE 2 % или revenue-sharing с мерчантом.

В одном operation_type можно (и часто нужно) иметь оба timing — это два разных движения денег, расчёт ведётся независимо.

3. Структура expression

expression — это JS-выражение (не statement), исполняемое через node:vm (src/rule-engine/evaluator.ts).

Доступ в sandbox:

  • amount: number — сумма intent в satang;
  • currency: string — например 'THB';
  • operationType: string;
  • metadata: Record<string, unknown> — произвольный контекст intent;
  • Math — единственный node-глобал, проброшен явно.

Никаких require, process, Buffer, fetch — sandbox изолирован. Timeout 100 мс, иначе Fee rule eval error.

Контракт результата:

[
  { account: string,  amount: number },   // amount — non-negative finite, satang, дробная часть отбрасывается
  ...
]

Несоответствие формату (не массив, отрицательный amount, не-строковый account) → EVAL_ERROR (422). Каждое правило в одном вызове calculateFees агрегируется по account — если два правила пишут на тот же equity-счёт, их amount складываются.

Типовые формы:

Вид Пример
Процент [{ account: 'system.revenue.THB', amount: Math.floor(amount * 0.015) }]
Фикс [{ account: 'system.revenue.THB', amount: 200 }]
Mixed (мин + %) [{ account: 'system.revenue.THB', amount: Math.max(500, Math.floor(amount * 0.01)) }]
Split [{ account: 'system.revenue.THB', amount: Math.floor(amount * 0.01) }, { account: 'system.revenue.network.THB', amount: Math.floor(amount * 0.005) }]

4. to_account_name — куда зачисляется комиссия

Комиссия — это доход системы, поэтому получатель — equity-счёт. Канонический destination:

  • system.revenue.THB — общий счёт выручки для THB.
  • system.revenue.<line>.THB — если по плану счетов есть детализация по продукту/каналу.

Не отправляй комиссию на user-, merchant-, transit- или nostro-счёт — это нарушит инвариант transit.balance = 0 и сломает sweep. PM не валидирует account строковым шаблоном — ответственность на тебе.

5. Пример: P2P_TRANSFER 1.5 %

Цель: при каждом P2P_TRANSFER отправитель платит 1.5 % сверх суммы на system.revenue.THB, кроме помеченных vip (для них 0.5 %) и fee_exempt (без комиссии).

В seed (drizzle/seed.ts) уже есть эталон — две строки:

{
  name:          'P2P базовая комиссия 1.5%',
  operationType: 'P2P_TRANSFER',
  expression:    '[{ account: "system.revenue.THB", amount: Math.floor(amount * 0.015) }]',
  timing:        'PRE',
  tagsInclude:   [],
  tagsExclude:   ['vip', 'fee_exempt'],
  priority:      0,
  active:        true,
},
{
  name:          'P2P VIP — сниженная комиссия 0.5%',
  operationType: 'P2P_TRANSFER',
  expression:    '[{ account: "system.revenue.THB", amount: Math.floor(amount * 0.005) }]',
  timing:        'PRE',
  tagsInclude:   ['vip'],
  tagsExclude:   ['fee_exempt'],
  priority:      10,
  active:        true,
},

Эквивалентный SQL (если оформляешь не через seed, а отдельной миграцией):

-- drizzle/migrations/NNNN_p2p_fee_rules.sql
INSERT INTO pm.fee_rule (name, operation_type, expression, timing, tags_include, tags_exclude, priority, active)
VALUES
  ('P2P базовая комиссия 1.5%',
   'P2P_TRANSFER',
   '[{ account: "system.revenue.THB", amount: Math.floor(amount * 0.015) }]',
   'PRE', '{}', ARRAY['vip','fee_exempt'], 0, true),
  ('P2P VIP — сниженная комиссия 0.5%',
   'P2P_TRANSFER',
   '[{ account: "system.revenue.THB", amount: Math.floor(amount * 0.005) }]',
   'PRE', ARRAY['vip'], ARRAY['fee_exempt'], 10, true);

Tag-логика (см. calculateFees):

  • tags_include = '{}' ⇒ правило применяется ко всем; иначе — только если все теги include присутствуют в metadata.tags (<@ оператор text[]).
  • tags_exclude = '{}' ⇒ исключений нет; иначе — правило не применяется, если хотя бы один тег exclude есть в metadata.tags (&&).
  • priority — только порядок логирования (ORDER BY priority ASC); агрегация по account идёт независимо от него.

6. Dry-run перед apply (обязательно)

Перед миграцией прогони выражение через POST /admin/fee-rules/dry-run (src/admin/fee-rules.ts) — этот endpoint исполняет ровно тот же sandbox, что и runtime, но ничего не пишет в БД. Так ты ловишь синтаксические ошибки, неверный shape и переполнения до того, как они попадут в production.

Endpoint требует HMAC-подпись (X-Service-Id, X-Timestamp, X-Signature) — используй сервис с разрешением вызывать админ-ручки (admin-tool / admin-panel).

Curl-пример (тело — выражение и контекст):

TS=$(date +%s%3N)
BODY='{
  "expression": "[{ account: \"system.revenue.THB\", amount: Math.floor(amount * 0.015) }]",
  "context": {
    "operationType": "P2P_TRANSFER",
    "amount": 100000,
    "currency": "THB",
    "metadata": { "tags": [] }
  }
}'
SIG=$(printf '%s' "POST\n/admin/fee-rules/dry-run\n${TS}\n${BODY}" \
      | openssl dgst -sha256 -hmac "$ADMIN_TOOL_SECRET" -binary | xxd -p -c 999)

curl -sS -X POST http://localhost:3000/admin/fee-rules/dry-run \
  -H 'Content-Type: application/json' \
  -H "X-Service-Id: admin-tool" \
  -H "X-Timestamp: ${TS}" \
  -H "X-Signature: ${SIG}" \
  -d "$BODY"

Ожидаемый ответ (amount=100000 satang = 1000 THB, 1.5 % = 15 THB = 1500 satang):

{
  "splits":   [{ "account": "system.revenue.THB", "amount": "1500" }],
  "totalFee": "1500"
}

Несовпадение чисел = баг в формуле. Не апплай миграцию до зелёного dry-run.

Точный формат канонизации для подписи (метод, путь, timestamp, raw body) бери из src/auth/hmacPlugin.ts\n между полями, без trailing newline.

7. Apply

Если правило живёт в seed (типичный путь для базовых правил, как P2P 1.5 %):

npm run db:seed

Seed идемпотентен — onConflictDoNothing() по PK, повторный запуск ничего не сломает.

Если правило живёт в отдельной миграции (для новых operation_type, добавленных после релиза):

npx drizzle-kit migrate

Никаких ручных INSERT в БД мимо seed/миграций — правило PM.

8. Тесты

Под каждое нетривиальное правило — тест в test/rule-engine/:

  • fee-calculator.test.ts — интеграция: посеять правило в тестовую БД, вызвать calculateFees() с разными amount/tags, сверить splits.
  • evaluator.test.ts — юнит на чистое выражение через evalFeeExpression() без БД.

Минимум для каждого нового правила:

  1. Базовый кейс (без тегов) — комиссия посчитана.
  2. tags_exclude срабатывает — комиссия = 0.
  3. Граничные значения amount (1, 100, переполнение Number.MAX_SAFE_INTEGER если применимо).
  4. timing правильный — calculateFees(ctx, db, 'PRE') и 'POST' дают ожидаемые подмножества.

9. Обнови CHANGELOG.md

Запиши в projects/payment-manager/CHANGELOG.md: какое правило добавил, для какого operation_type, timing, и зачем. Без формул в changelog — только смысл.

10. Типовые ошибки

  • /admin/dry-run — такого endpoint нет. Правильный путь — POST /admin/fee-rules/dry-run.
  • Канал ADMIN_TRANSFER — это operation_type, а не канал. Канал — ADMIN. Для fee_rule канал вообще не задаётся.
  • return [...] в expression — node:vm оборачивает выражение в (...), return вне функции вызовет SyntaxError. Только bare expression.
  • amount в satang vs THB. В контексте amount всегда в satang (1 THB = 100 satang). Не делить на 100 для процента — * 0.015 работает на satang.
  • Math.round/Math.ceil — sandbox округляет вниз через BigInt(Math.floor(split.amount)), ceiling уже не вернёшь. Считай явно Math.floor, чтобы не было сюрпризов с дробями.
  • Отрицательный или NaN amount в expression → EVAL_ERROR 422. Защищайся Math.max(0, ...).
  • Получатель комиссии — user/transit/nostro. Только equity (system.revenue.*). Иначе sweep сломается.

11. Acceptance / чек-лист перед merge

  • timing (PRE/POST) выбран осознанно и задокументирован в name.
  • expression прогнан через POST /admin/fee-rules/dry-run минимум для трёх amount (мин/типичный/макс).
  • Получатель — equity-счёт (system.revenue.*).
  • tags_include/tags_exclude не конфликтуют с уже существующими правилами того же operation_type.
  • Seed или миграция идемпотентны (onConflictDoNothing / ON CONFLICT DO NOTHING).
  • npm run db:seed или npx drizzle-kit migrate отработал локально без ошибок.
  • Тесты в test/rule-engine/ зелёные.
  • CHANGELOG.md обновлён.

12. См. также