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

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_chktiming IN ('PRE', 'POST').

Связи

Логических FK нет; правила привязываются к платежу через operation_type и теги:

  • pm.fee_rule.operation_typepm.intent.operation_type — выборка кандидатов;
  • pm.fee_rule.tags_include / tags_excludepm.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" }