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

Модуль limits/ — лимиты и step-up политики

Модуль src/limits/ объединяет два независимых подмеханизма платёжного контроля: жёсткие финансовые лимиты (per-tx, дневные, месячные) и step-up policies (требование дополнительной аутентификации). Оба читают pm.tx_history, но решают разные задачи и описаны раздельно.

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

В модуле живут два аспекта, которые исторически объединены одним именем «limits», но различаются по природе:

  1. Финансовые лимиты — функция checkLimits загружает активные правила из pm.limit_rule, вычисляет накопленные значения за окно через SUM/COUNT по pm.tx_history и бросает LimitExceededError, если новая операция превысит порог. Сопровождается заглушкой recordUsage — счётчики не материализуются, использование выводится из истории по требованию.
  2. 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 (массивные операторы @> / &&). Для каждого правила:

  1. Дополнительный in-memory фильтр по direction, tagsInclude/Exclude (зеркало SQL-WHERE — defence-in-depth и совместимость с моками).
  2. Если window = PER_TX — синхронная проверка ctx.amount > rule.amountLimit.
  3. Иначе — агрегирующий запрос: SUM(amount) и COUNT(*) из pm.tx_history INNER JOIN pm.intent ON intent_id за окно, с фильтрами по userId, currency, direction, operationType, channel, intent.status = 'SETTLED'.
  4. Если 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-инварианта (см. КРИТИЧЕСКИЙ ИНВАРИАНТ выше).

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

  1. recordUsage() — Redis-кеш счётчиков. Сейчас функция пуста; место зарезервировано под материализованные счётчики usage:{userId}:{ruleId}:{window} с TTL до конца окна. Включается, если SUM-агрегация по tx_history начнёт упираться в latency P99.
  2. Дополнительные операторы в condition для evaluatePolicy — см. policies.md (merchant_category_in, device_trust_score_lt, и т. п.).
  3. Per-currency окна — сейчас DAILY/MONTHLY считаются в UTC; при выходе за пределы Таиланда может понадобиться Asia/Bangkok или таймзона пользователя.
  4. Лимиты на merchant/agent аккаунты. В текущей реализации LimitContext.userId — это владелец аккаунта по tb_account_map. Для multi-entity сценариев (merchant с несколькими операторами) может потребоваться отдельная сущность entity_id в правиле.