Модуль limits/ — лимиты и step-up политики¶
Модуль src/limits/ объединяет два независимых подмеханизма платёжного контроля: жёсткие финансовые лимиты (per-tx, дневные, месячные) и step-up policies (требование дополнительной аутентификации). Оба читают pm.tx_history, но решают разные задачи и описаны раздельно.
Назначение модуля¶
В модуле живут два аспекта, которые исторически объединены одним именем «limits», но различаются по природе:
- Финансовые лимиты — функция
checkLimitsзагружает активные правила изpm.limit_rule, вычисляет накопленные значения за окно черезSUM/COUNTпоpm.tx_historyи бросаетLimitExceededError, если новая операция превысит порог. Сопровождается заглушкойrecordUsage— счётчики не материализуются, использование выводится из истории по требованию. - Step-up политики — функция
evaluatePolicyрассчитывает требуемый уровень дополнительной аутентификации (PIN/BIOMETRIC/OTP/KYC_UPLIFT) на основе политик изpm.auth_policies. Это отдельная подсистема — детали в policies.md.
Оба подмеханизма вызываются до резервирования средств в TigerBeetle и являются guard-условиями для приёма платежа.
Структура файлов¶
src/limits/
├── check-limits.ts — жёсткие лимиты по pm.limit_rule (SUM/COUNT за окно)
├── record-usage.ts — заглушка (usage выводится из tx_history on demand)
├── evaluate-policy.ts — step-up engine (часть подсистемы policies)
└── types.ts — LimitContext (общий вход для checkLimits/recordUsage)
Ключевые типы¶
LimitContext (types.ts)¶
export interface LimitContext {
userId: number
operationType: string // 'P2P_TRANSFER' | 'INVOICE_PAYMENT' | …
channel: string // 'INTERNAL' | 'IPPS' | 'QP' | 'ADMIN' | …
direction: 'DEBIT' | 'CREDIT'
amount: bigint // в минорных единицах валюты
currency: string
tags: string[] // произвольные теги для tagsInclude/tagsExclude фильтрации
}
LimitWindow¶
Окно агрегации в правиле — поле window в pm.limit_rule (CHECK constraint):
| Значение | Семантика |
|---|---|
PER_TX |
Лимит на одну операцию — сравнение ctx.amount с amountLimit. |
DAILY |
Сумма с начала текущих UTC-суток (по tx_history.createdAt). |
MONTHLY |
Сумма с начала текущего UTC-месяца. |
Границы окон строятся через windowStart() в UTC — без учёта таймзоны пользователя (важно для аудита и предсказуемости).
LimitDirection¶
Направление движения средств — поле direction в правиле и в LimitContext:
| Значение | Семантика |
|---|---|
DEBIT |
Списание (исходящий платёж) с точки зрения LimitContext.userId. |
CREDIT |
Зачисление (входящий платёж). |
BOTH |
Только в pm.limit_rule.direction — применяется к обоим направлениям. |
КРИТИЧЕСКИЙ ИНВАРИАНТ — определение direction¶
Direction (DEBIT/CREDIT) определяется через pm.tb_account_map.userId, а НЕ через префикс имени аккаунта (account_name).
То есть: вызывающий код (handler.ts) обязан резолвить account_name → userId через pm.tb_account_map и формировать LimitContext.direction исходя из того, чей это аккаунт. Сравнение типа accountName.startsWith('user:') для определения направления — запрещено (см. memory: feedback-limit-direction).
Эта инвариантность критична для мультиаккаунтных сценариев (merchant, agent, escrow), где одно и то же имя аккаунта может относиться к разным пользователям, и единственный надёжный источник владельца — таблица tb_account_map.
Основные функции¶
checkLimits(ctx, db) → void (check-limits.ts)¶
Главная функция модуля. Загружает все активные правила (limitRule.active = true), отфильтрованные по operationType (* — wildcard), channel (NULL — wildcard), tagsInclude/tagsExclude (массивные операторы @> / &&). Для каждого правила:
- Дополнительный in-memory фильтр по
direction,tagsInclude/Exclude(зеркало SQL-WHERE — defence-in-depth и совместимость с моками). - Если
window = PER_TX— синхронная проверкаctx.amount > rule.amountLimit. - Иначе — агрегирующий запрос:
SUM(amount)иCOUNT(*)изpm.tx_history INNER JOIN pm.intent ON intent_idза окно, с фильтрами поuserId,currency,direction,operationType,channel,intent.status = 'SETTLED'. - Если
sumUsed + ctx.amount > amountLimitилиcountUsed + 1 > countLimit— выбрасываетсяLimitExceededErrorс деталями (имя правила, окно, лимит, текущее значение, запрошенная сумма).
Важно: в выборку попадают только успешные платежи (intent.status = 'SETTLED') — отменённые и отклонённые операции не расходуют лимит.
Псевдо-SQL агрегирующего запроса для DAILY/MONTHLY правила:
SELECT COALESCE(SUM(h.amount), 0) AS sum_used,
COUNT(*) AS count_used
FROM pm.tx_history h
JOIN pm.intent i ON i.id = h.intent_id
WHERE h.user_id = :userId
AND h.currency = :currency
AND h.created_at >= :windowStart -- UTC начало суток / месяца
AND i.status = 'SETTLED'
AND h.direction = :direction -- если rule.direction <> 'BOTH'
AND h.operation_type = :operationType -- если rule.operation_type <> '*'
AND i.channel = :channel; -- если rule.channel IS NOT NULL
Структура исключения LimitExceededError.details (используется в API-ответе 422):
{
ruleName: string // имя нарушенного правила
window: 'PER_TX' | 'DAILY' | 'MONTHLY'
limitType: 'amount' | 'count'
limit: bigint // порог из rule
current: bigint // текущее накопленное значение
requested: bigint // ctx.amount
}
recordUsage(ctx, db) → void (record-usage.ts)¶
Заглушка. Тело — пустое: // Usage is derived from pm.tx_history on demand — no counters to maintain. Зарезервирована под будущий Redis-кеш счётчиков, если профилирование покажет, что агрегация SUM по tx_history становится дорогой. Сейчас вызов безопасен и noop — оставлен в API ради симметрии с checkLimits и точки расширения.
evaluatePolicy(req) → PolicyDecision (evaluate-policy.ts)¶
Это часть подсистемы step-up policies, физически живущая в src/limits/. Подробное описание сигнатуры, scopes (global / app:X / merchant:X), полей condition (amount_lt, daily_cumulative_gte, new_payee и др.), интеграции с pm.auth_policies и фронтендом — в отдельном документе policies.md.
Здесь фиксируем только факт: функция читает те же tx_history/intent для вычисления dailyCumulative и isNewPayee, поэтому удобно соседствует с checkLimits.
Жизненный цикл¶
POST /intents (handler.ts)
│
├─► quoteFees() ← рассчёт комиссий
│
├─► checkLimits(LimitContext, db) ← ❶ ЖЁСТКИЕ ЛИМИТЫ
│ │
│ └─ throw LimitExceededError → 422 в API
│
├─► evaluatePolicy(PolicyRequest) ← ❷ STEP-UP (см. policies.md)
│ │
│ └─ requireStepUp ≠ NONE → 401 + reasonCode
│
├─► reserveFunds(TigerBeetle) ← резервирование (pending transfer)
│
├─► sagaExecute() ← settle/cancel
│
└─► recordUsage(ctx, db) ← ❸ no-op (под будущий Redis cache)
checkLimits— до резервирования. Если правило нарушено, ни одного движения в TigerBeetle ещё не произведено — откатывать нечего.evaluatePolicy— до резервирования. Если требуется step-up, клиент получает401+reasonCodeи должен повторить запрос с подтверждением.recordUsage— послеsettle. Сейчас ничего не делает, но сохранено как точка вызова: когда появится Redis-кеш, инвалидация/инкремент будут происходить именно здесь.
Конфигурация¶
Источник правил — таблица pm.limit_rule. Подробное описание полей, индексов и примеров — в ../reference/database/12-limit-rule.md.
Ключевые поля:
| Поле | Тип | Назначение |
|---|---|---|
operation_type |
varchar |
'P2P_TRANSFER', 'INVOICE_PAYMENT', …, или '*' (wildcard). |
channel |
varchar? |
'INTERNAL', 'IPPS', 'QP', 'ADMIN', NULL = любой канал. |
direction |
varchar |
'DEBIT' / 'CREDIT' / 'BOTH'. |
window |
varchar |
'PER_TX' / 'DAILY' / 'MONTHLY'. |
amount_limit |
bigint? |
Лимит по сумме (NULL = не проверять). |
count_limit |
int? |
Лимит по числу транзакций за окно (NULL = не проверять). |
tags_include |
text[] |
Все теги должны присутствовать в ctx.tags. |
tags_exclude |
text[] |
Ни один тег не должен присутствовать в ctx.tags. |
priority |
int |
Порядок применения (меньше — раньше). |
active |
bool |
Глобальное включение/выключение правила. |
CHECK-ограничения таблицы гарантируют:
- direction ∈ {DEBIT, CREDIT, BOTH}
- window ∈ {DAILY, MONTHLY, PER_TX}
- amount_limit IS NOT NULL OR count_limit IS NOT NULL (хотя бы один лимит задан).
Управление правилами — через Admin Panel (см. cross-service документ ADMIN_PANEL.md в корне PM).
Тестирование¶
Тесты модуля находятся в test/limits/:
test/limits/
├── check-limits.test.ts — сценарии PER_TX / DAILY / MONTHLY, теги, channel/operationType
└── evaluate-policy.test.ts — step-up engine, scope ordering, condition matching
Тесты используют in-memory mock БД (см. test/helpers/), который повторяет фильтрацию правил и агрегацию tx_history для предсказуемости в unit-режиме. Integration-тесты с реальной PostgreSQL живут в test/integration/ (если применимы).
Минимальный покрытый набор кейсов для checkLimits:
PER_TX:amount = limit(проходит),amount = limit + 1(бросает).DAILY: накопление с двух SETTLED-транзакций суммируется корректно;CANCELED/FAILEDне учитываются.MONTHLY: смена месяца сбрасывает агрегат (через подстановкуcreated_atв фикстуре).tags_include/tags_exclude: правило применяется/пропускается по тегам.direction = 'BOTH': правило срабатывает и дляDEBIT, и дляCREDIT.operation_type = '*': правило применяется ко всем типам операций.channel IS NULL: правило применяется ко всем каналам (INTERNAL,IPPS,QP,ADMIN).
Связанные модули¶
- intent.md — вызывает
checkLimitsиevaluatePolicyиз основного handler при обработкеPOST /intents. - policies.md — полное описание step-up подсистемы;
evaluate-policy.tsфизически живёт здесь, но логически принадлежит туда. - ../reference/database/12-limit-rule.md — справочник таблицы
pm.limit_rule(DDL, индексы, миграции). pm.tx_history— источник агрегатов дляcheckLimitsиevaluatePolicy; описание схемы см. в reference/database.pm.tb_account_map— источник истины дляdirection-инварианта (см. КРИТИЧЕСКИЙ ИНВАРИАНТ выше).
Заготовки на будущее¶
recordUsage()— Redis-кеш счётчиков. Сейчас функция пуста; место зарезервировано под материализованные счётчикиusage:{userId}:{ruleId}:{window}с TTL до конца окна. Включается, если SUM-агрегация поtx_historyначнёт упираться в latency P99.- Дополнительные операторы в
conditionдляevaluatePolicy— см. policies.md (merchant_category_in,device_trust_score_lt, и т. п.). - Per-currency окна — сейчас
DAILY/MONTHLYсчитаются в UTC; при выходе за пределы Таиланда может понадобитьсяAsia/Bangkokили таймзона пользователя. - Лимиты на merchant/agent аккаунты. В текущей реализации
LimitContext.userId— это владелец аккаунта поtb_account_map. Для multi-entity сценариев (merchant с несколькими операторами) может потребоваться отдельная сущностьentity_idв правиле.