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

Модуль rule-engine

Sandboxed-вычислитель комиссий: SQL-фильтрация активных правил из pm.fee_rule, вычисление JavaScript-выражений через node:vm с таймаутом 100 мс и агрегация результатов в массив FeeSplit.

Назначение модуля

rule-engine отвечает за расчёт комиссий по платёжному намерению до и после расчёта (settlement). Модуль не знает о TigerBeetle, аккаунтах и саге — он принимает контекст (operationType, amount, currency, metadata), достаёт из БД подходящие правила и возвращает структуру FeeResult со списком получателей комиссии и их суммами.

Ключевые свойства:

  • Песочница — выражения исполняются в изолированном vm.Context, без доступа к Node.js globals (process, require, fs).
  • Жёсткий таймаут — 100 мс на одно выражение; защита от бесконечных циклов в правилах.
  • Декларативные правила — каждое правило в pm.fee_rule — это строка JavaScript-выражения, возвращающая массив { account, amount }.
  • Tag-фильтрация на уровне SQLtags_include / tags_exclude отрабатывают в WHERE, до загрузки правил в Node.js.

Модуль вызывается дважды на один интент: один раз для PRE-комиссий (до settlement) и один раз для POST-комиссий (после).

Структура файлов

src/rule-engine/
├── evaluator.ts        — sandbox через node:vm, оценка одного выражения
├── fee-calculator.ts   — SQL-фильтр активных правил + агрегация splits
└── types.ts            — FeeSplit, FeeResult, RuleContext, ResolvedFeeSplit

Связанный код:

  • src/admin/fee-rules.ts — Fastify-роут POST /admin/fee-rules/dry-run для проверки выражения без записи в БД.
  • src/shared/schema.ts — Drizzle-схема таблицы pm.fee_rule.

Ключевые типы

RuleContext (входные данные для выражения)

interface RuleContext {
  operationType: string                    // например 'P2P_INTERNAL', 'TOPUP'
  amount:        number                    // сумма в satang
  currency:      string                    // ISO 4217, например 'THB'
  metadata:      Record<string, unknown>   // произвольный JSON из интента; metadata.tags используется для фильтрации
}

Эти поля становятся переменными в sandbox-окружении выражения.

FeeSplit (результат одного правила)

interface FeeSplit {
  account: string         // имя аккаунта-получателя комиссии (например 'fee.platform')
  amount:  bigint         // сумма комиссии в satang
  timing:  'PRE' | 'POST' // когда удерживается
}

FeeResult (агрегированный ответ модуля)

interface FeeResult {
  splits:   FeeSplit[]   // все splits всех правил, агрегированные по account
  totalFee: bigint       // сумма всех splits
}

ResolvedFeeSplit (после резолюции имён аккаунтов в TB IDs)

interface ResolvedFeeSplit {
  accountName: string
  tbAccountId: bigint
  amount:      bigint
  timing:      'PRE' | 'POST'
}

Используется уже вне rule-engine — в settlement-writer, когда имена account маппятся в TigerBeetle account IDs.

FeeTiming'PRE' | 'POST'

  • PRE — удерживается сверх amount у отправителя до резервирования средств. То есть отправитель платит amount + sumPre. Применяется, когда комиссия — это «надбавка» (например, fee пользователя за P2P-перевод).
  • POST — удерживается из amount у получателя после settlement. Получатель получает amount - sumPost. Применяется, когда комиссия удерживается с мерчанта/получателя (например, эквайринговая комиссия).

Основные функции

evalFeeExpression(expression, ctx): EvalSplit[]

Файл: src/rule-engine/evaluator.ts.

Оценивает одно JavaScript-выражение в изолированном контексте.

  • Создаёт vm.Script из (${expression}) — оборачивание в скобки нужно, чтобы голый [] парсился как expression, а не как блок.
  • Создаёт vm.Context с ограниченным набором глобалов:
  • Math — арифметика (Math.floor, Math.min, Math.max).
  • operationType, amount, currency, metadata — из RuleContext.
  • НЕ доступны: process, require, fs, console, Buffer, setTimeout и любые другие Node-API.
  • Запускает выражение с timeout: 100 ms. Превышение → Error с логированием.
  • Валидирует форму результата: Array<{ account: string, amount: number }>, amount >= 0 и Number.isFinite.
  • Конвертирует amount через BigInt(Math.floor(amount)) — округление вниз до satang.

calculateFees(ctx, db, timing): Promise<FeeResult>

Файл: src/rule-engine/fee-calculator.ts.

Главная экспортируемая функция модуля. Алгоритм:

  1. Извлекает metadata.tags из контекста (если массив — иначе []).
  2. SELECT из pm.fee_rule с фильтром:
  3. operation_type = ctx.operationType
  4. active = true
  5. timing = $timing (PRE или POST)
  6. tags_include = '{}' OR tags_include <@ intentTags — оператор PostgreSQL <@ означает «содержится в». Если у правила есть tags_include, у интента должны быть все эти теги.
  7. tags_exclude = '{}' OR NOT (tags_exclude && intentTags) — оператор && означает «пересекается». Если у интента есть хотя бы один запрещённый тег, правило не применяется.
  8. Сортировка ORDER BY priority ASC — правила с меньшим priority вычисляются первыми (но порядок не влияет на сумму, только на стабильный порядок логов).
  9. Для каждого правила вызывает evalFeeExpression(rule.expression, ctx).
  10. Агрегирует все splits по полю account через Map<string, bigint> — несколько правил, начисляющих на один аккаунт, суммируются.
  11. Возвращает { splits, totalFee }.

Любая ошибка evalFeeExpression пробрасывается наружу — обрабатывается вызывающим кодом (saga отклоняет интент с ошибкой).

Жизненный цикл

PRE-фаза

Вызывается в src/intent/handler.ts перед резервированием средств в TigerBeetle:

intent.create
  → calculateFees(ctx, db, 'PRE')
  → reserve(amount + sumPre) с отправителя в transit
  → settle:
      - amount → получателю
      - sumPre → fee-аккаунтам (PRE splits)

Если PRE-расчёт упадёт, интент отклоняется до любых движений по TB.

POST-фаза

Вызывается в settlement-writer после успешного settle отправителя → transit → получателя:

settlement-writer
  → calculateFees(ctx, db, 'POST')
  → transfer: получатель → fee-аккаунты на сумму sumPost

POST-комиссии могут начисляться на основе фактически переведённой суммы, в том числе с учётом курса (для будущих multi-currency сценариев).

INTERNAL_P2P

Канал INTERNAL (см. intent.md) выполняет PRE+settle+POST в одной HTTP-транзакции — оба вызова calculateFees происходят в одном запросе.

Конфигурация

Таблица pm.fee_rule

Полная схема описана в ../reference/database/05-fee-rule.md. Ключевые колонки:

Колонка Тип Назначение
operation_type text Фильтр по типу операции (P2P_INTERNAL, TOPUP, ...)
timing text 'PRE' или 'POST'
expression text JavaScript-выражение, возвращающее Array<{account,amount}>
priority int Порядок применения (только для логов)
active bool Soft-disable без удаления
tags_include text[] Все эти теги должны быть в metadata.tags интента
tags_exclude text[] Если хоть один тег есть в интенте — правило пропускается

Переменные окружения

  • ADMIN_SECRET — HMAC-секрет для /admin/fee-rules/* endpoint-ов (включая dry-run). Используется в общем HMAC middleware Payment Manager-а.

Dry-run endpoint

POST /admin/fee-rules/dry-run — проверка выражения без записи в БД. Используется UI админ-панели для интерактивного редактирования правил.

Тело запроса:

{
  "expression": "[{ account: 'fee.platform', amount: amount * 0.01 }]",
  "context": {
    "operationType": "P2P_INTERNAL",
    "amount":         10000,
    "currency":       "THB",
    "metadata":       { "tags": ["promo"] }
  }
}

Ответ 200:

{
  "splits":   [{ "account": "fee.platform", "amount": "100" }],
  "totalFee": "100"
}

Коды ошибок:

  • 401 — HMAC-аутентификация не прошла.
  • 422 VALIDATION_ERROR — невалидное тело запроса (Zod-ошибка).
  • 422 EVAL_ERROR — выражение упало или вернуло неправильную форму.

Эндпоинт документирован в ../api/admin.md.

Тестирование

Папка test/rule-engine/:

test/rule-engine/
├── evaluator.test.ts        — sandbox изоляция, таймаут 100 мс, валидация формы результата
└── fee-calculator.test.ts   — SQL-фильтрация по operation_type / timing / tags, агрегация

Тесты используют общий fixture-setup из ../testing/patterns.md: in-memory PG через pg-mem (или Testcontainers — зависит от теста), seed таблицы pm.fee_rule, проверка детерминированных splits.

Связанные модули

  • intent.md — handler.ts вызывает calculateFees('PRE') перед резервированием.
  • intent.md — settlement-writer (часть intent-модуля) вызывает calculateFees('POST') после settle.
  • ../api/admin.mdPOST /admin/fee-rules/dry-run и CRUD над fee_rule.
  • ../reference/database/05-fee-rule.md — DDL таблицы pm.fee_rule, индексы, ограничения.

Заготовки на будущее

  • Multi-currency fee rules — сейчас currency доступна в sandbox как переменная, но конверсия и валидация валюты splits не реализованы. Планируется: поле target_currency в pm.fee_rule, FX-таблица для конверсии, валидация валюты получателя.
  • Глобальный лимит комиссий — защитный cap (например, не более 5% от amount) для предотвращения ошибок в выражениях.
  • Стабильная сортировка splits для аудита — сейчас порядок splits в FeeResult зависит от порядка вставки в Map; для детерминированного аудита может потребоваться ORDER BY account.
  • Versioning правил — история изменений expression с указанием автора и времени, для разбора инцидентов.