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):
Несовпадение чисел = баг в формуле. Не апплай миграцию до зелёного dry-run.
Точный формат канонизации для подписи (метод, путь, timestamp, raw body) бери из
src/auth/hmacPlugin.ts—\nмежду полями, без trailing newline.
7. Apply¶
Если правило живёт в seed (типичный путь для базовых правил, как P2P 1.5 %):
Seed идемпотентен — onConflictDoNothing() по PK, повторный запуск ничего не сломает.
Если правило живёт в отдельной миграции (для новых operation_type, добавленных после релиза):
Никаких ручных INSERT в БД мимо seed/миграций — правило PM.
8. Тесты¶
Под каждое нетривиальное правило — тест в test/rule-engine/:
fee-calculator.test.ts— интеграция: посеять правило в тестовую БД, вызватьcalculateFees()с разнымиamount/tags, сверить splits.evaluator.test.ts— юнит на чистое выражение черезevalFeeExpression()без БД.
Минимум для каждого нового правила:
- Базовый кейс (без тегов) — комиссия посчитана.
tags_excludeсрабатывает — комиссия = 0.- Граничные значения
amount(1, 100, переполнениеNumber.MAX_SAFE_INTEGERесли применимо). 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_ERROR422. Защищайся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. См. также¶
src/rule-engine/fee-calculator.ts—calculateFees, главный потребитель.src/rule-engine/evaluator.ts— sandboxnode:vm(timeout 100 мс), контракт expression.src/rule-engine/types.ts—FeeSplit,RuleContext.src/admin/fee-rules.ts— handlerPOST /admin/fee-rules/dry-run.drizzle/seed.ts— образцы P2P / MINIAPP_CHARGE fee rules.docs/dev/modules/rule-engine.md— общая архитектура движка правил.