05 fee rule
Таблица pm.fee_rule хранит правила комиссий: какие fee-сплиты применять к платежу, в какой момент (PRE/POST) и в каком порядке.
Имя¶
pm.fee_rule
Назначение¶
fee_rule — это конфигурация tariffs-движка PM. Каждая строка — отдельное правило, которое описывает один сценарий взимания комиссии:
- что взимать — выражение
expression(sandboxed JavaScript), возвращающее массив{account, amount}(account — этоtb_account_map.account_nameполучателя комиссии, amount — сумма в satang); - с кого / когда — поле
timing:PRE— fee удерживается с отправителя до основного перевода (и расход уменьшает доступную к переводу сумму),POST— fee удерживается с получателя после основного перевода (получатель получаетamount - postFee, разница уходит в revenue/equity-account); - в каких случаях — фильтры по
operation_type(обязателен) и тегам (tags_include,tags_exclude— массивы строк изintent.metadata.tags); - в каком порядке —
priority(ASC, меньшее значение — раньше; см. ниже про композицию сплитов).
Правило с active = false не учитывается. Правила одного operation_type композируются: все подходящие сплиты по одному и тому же account суммируются (см. src/rule-engine/fee-calculator.ts).
Получатель комиссии обычно — equity-/revenue-аккаунт системы (system.revenue.THB), реже — merchant-settlement или agent-settlement аккаунт (для revenue-sharing; на момент написания не засидено — см. NOTE в drizzle/seed.ts).
DDL¶
Из drizzle/migrations/0000_init.sql:
CREATE TABLE "pm"."fee_rule" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"operation_type" varchar(50) NOT NULL,
"expression" text NOT NULL,
"timing" char(4) DEFAULT 'PRE' NOT NULL,
"tags_include" text[] DEFAULT '{}'::text[] NOT NULL,
"tags_exclude" text[] DEFAULT '{}'::text[] NOT NULL,
"priority" integer DEFAULT 0 NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "fee_rule_timing_chk" CHECK ("pm"."fee_rule"."timing" IN ('PRE', 'POST'))
);
Drizzle-описание — src/shared/schema.ts (раздел B4: Fee Rule).
Поля¶
| Колонка | Тип | NULL | Default | Описание |
|---|---|---|---|---|
id |
serial |
NO | nextval(...) |
PK, технический. |
name |
varchar(100) |
NO | — | Человекочитаемое имя для UI/логов (например, P2P базовая комиссия 1.5%). Не уникально. |
operation_type |
varchar(50) |
NO | — | Тип операции, к которому применимо правило (P2P_TRANSFER, MINIAPP_CHARGE, IPPS_WITHDRAWAL, INVOICE_PAYMENT, …). Должен совпасть с intent.operation_type. |
expression |
text |
NO | — | JavaScript-выражение, исполняется в sandbox (node:vm). Должно возвращать массив [{account: string, amount: number}, …]. Доступны: Math, operationType, amount (satang), currency, metadata. Подробнее — см. «Связанный код». |
timing |
char(4) |
NO | 'PRE' |
PRE — fee удержать с отправителя до основного перевода (общая сумма списания = amount + preFee). POST — fee удержать с получателя после основного перевода (получатель получит amount - postFee). CHECK ограничивает значения. |
tags_include |
text[] |
NO | '{}' |
Если непусто — правило сработает только если ВСЕ теги из массива есть в intent.metadata.tags (оператор <@). Пустой массив = без ограничений. |
tags_exclude |
text[] |
NO | '{}' |
Если непусто — правило НЕ сработает, если в intent.metadata.tags есть ХОТЯ БЫ ОДИН тег из массива (оператор &&). Стандартные значения: fee_exempt (полное освобождение), vip (применяется отдельное VIP-правило). |
priority |
integer |
NO | 0 |
Порядок применения по возрастанию (ASC). Используется только для детерминированного логирования — фактическое суммирование сплитов коммутативно (см. композицию ниже). |
active |
boolean |
NO | true |
Soft-disable. Неактивные правила исключаются из выборки. |
created_at |
timestamptz |
NO | now() |
Время создания строки. |
Композиция: calculateFees выбирает ВСЕ активные правила для (operationType, timing), прошедшие фильтр по тегам, и суммирует сплиты по одинаковым account через splitMap. То есть несколько правил могут вместе сформировать комиссию (например, базовое 1.5% + дополнительное 0.5% на промо-кампанию).
Освобождение от комиссии: добавьте тег fee_exempt в intent.metadata.tags — он включён в tags_exclude всех seed-правил, поэтому ни одно из них не применится.
Индексы¶
В таблице нет дополнительных индексов кроме PK. Это сознательно: fee_rule — справочник на десятки строк, любой запрос всё равно завершается seq-scan-ом. Если правил станет много, можно добавить partial-index (operation_type) WHERE active.
CHECK-ограничение:
fee_rule_timing_chk—timing IN ('PRE', 'POST').
Связи¶
Логических FK нет; правила привязываются к платежу через operation_type и теги:
pm.fee_rule.operation_type↔pm.intent.operation_type— выборка кандидатов;pm.fee_rule.tags_include/tags_exclude↔pm.intent.metadata.tags(JSONB-массив строк) — фильтр;- результат расчёта (
pre_fee_amount,post_fee_amount) сохраняется вpm.intent; - сплиты материализуются в
pm.tx_history(по одной строкеDEBIT/CREDITна каждое движение) сfee_amountв строках, к которым относится комиссия; - account из сплита разрешается в TigerBeetle account через
pm.tb_account_map.
Связанный код¶
| Файл | Роль |
|---|---|
src/shared/schema.ts |
Drizzle-определение feeRule. |
src/rule-engine/fee-calculator.ts |
calculateFees(ctx, db, timing) — выборка правил + композиция сплитов. Вызывается дважды на платёж: для PRE и для POST. |
src/rule-engine/evaluator.ts |
evalFeeExpression(expression, ctx) — sandbox node:vm (timeout: 100ms), доступны Math, operationType, amount, currency, metadata. Результат проверяется на форму Array<{account: string, amount: number}>, amount приводится к bigint через Math.floor. |
src/rule-engine/types.ts |
FeeSplit, FeeResult, RuleContext, 'PRE' | 'POST'. |
src/admin/fee-rules.ts |
Админ-эндпоинт POST /admin/fee-rules/dry-run. |
drizzle/seed.ts |
Начальное наполнение тремя правилами (см. ниже). |
Эндпоинт dry-run¶
POST /admin/fee-rules/dry-run (HMAC-аутентификация, как все админ-роуты) принимает произвольное expression и context и возвращает то же, что вернул бы calculateFees, но без записи в БД и без применения к платежу. Используется в админке для проверки выражения перед сохранением правила.
Ошибки:
422 VALIDATION_ERROR— невалидный body (Zod-парсинг).422 EVAL_ERROR— выражение бросило исключение / превысилоtimeout: 100ms/ вернуло не массив / элемент массива не имеет формы{account: string, amount: number}/amountотрицательный или не конечный.
Подробный how-to по сочинению выражений и регистрации правил — в dev/cookbook/write-fee-rule.md (TODO, файл ещё не создан).
Примеры¶
Seed-строки¶
Из drizzle/seed.ts — три базовых правила (суммы в satang, 1 THB = 100 satang):
// 1. P2P базовая комиссия 1.5% — со всех, кроме VIP и fee_exempt
{
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,
}
// 2. P2P VIP — сниженная комиссия 0.5% (включается тегом 'vip')
{
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,
}
// 3. MINIAPP_CHARGE — POST-комиссия 2% (удерживается с получателя)
{
name: 'MINIAPP_CHARGE базовая комиссия 2%',
operationType: 'MINIAPP_CHARGE',
expression: '[{ account: "system.revenue.THB", amount: Math.floor(amount * 0.02) }]',
timing: 'POST',
tagsInclude: [],
tagsExclude: ['fee_exempt'],
priority: 0,
}
NOTE из seed: merchant revenue-sharing-правила (с динамическим merchant.${merchantId}.settlement.THB) не засидены — требуют расширения RuleContext (B4 Rule Engine roadmap).
Полезные SQL-запросы¶
-- Все активные правила для P2P-перевода с разбивкой по timing
SELECT id, name, timing, priority, expression
FROM pm.fee_rule
WHERE operation_type = 'P2P_TRANSFER'
AND active = true
ORDER BY timing, priority;
-- Сколько правил у каждого operation_type
SELECT operation_type, timing, count(*) AS rules
FROM pm.fee_rule
WHERE active = true
GROUP BY operation_type, timing
ORDER BY operation_type, timing;
-- Найти правила, освобождающие VIP-клиентов
SELECT id, name, operation_type
FROM pm.fee_rule
WHERE 'vip' = ANY(tags_exclude);
-- Деактивировать правило (soft-delete)
UPDATE pm.fee_rule SET active = false WHERE id = $1;
Пример dry-run-запроса¶
curl -X POST https://pm.example.com/admin/fee-rules/dry-run \
-H 'Content-Type: application/json' \
-H 'X-Service-Id: admin' \
-H 'X-Timestamp: ...' \
-H 'X-Signature: ...' \
-d '{
"expression": "[{ account: \"system.revenue.THB\", amount: Math.floor(amount * 0.015) }]",
"context": {
"operationType": "P2P_TRANSFER",
"amount": 10000,
"currency": "THB",
"metadata": { "tags": [] }
}
}'
# → { "splits": [{ "account": "system.revenue.THB", "amount": "150" }], "totalFee": "150" }