Модуль 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-фильтрация на уровне SQL —
tags_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: 100ms. Превышение →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.
Главная экспортируемая функция модуля. Алгоритм:
- Извлекает
metadata.tagsиз контекста (если массив — иначе[]). - SELECT из
pm.fee_ruleс фильтром: operation_type = ctx.operationTypeactive = truetiming = $timing(PRE или POST)tags_include = '{}' OR tags_include <@ intentTags— оператор PostgreSQL<@означает «содержится в». Если у правила естьtags_include, у интента должны быть все эти теги.tags_exclude = '{}' OR NOT (tags_exclude && intentTags)— оператор&&означает «пересекается». Если у интента есть хотя бы один запрещённый тег, правило не применяется.- Сортировка
ORDER BY priority ASC— правила с меньшим priority вычисляются первыми (но порядок не влияет на сумму, только на стабильный порядок логов). - Для каждого правила вызывает
evalFeeExpression(rule.expression, ctx). - Агрегирует все splits по полю
accountчерезMap<string, bigint>— несколько правил, начисляющих на один аккаунт, суммируются. - Возвращает
{ 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:
Коды ошибок:
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.md —
POST /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с указанием автора и времени, для разбора инцидентов.