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

Cookbook: Написать новое limit_rule

Краткое руководство по добавлению нового лимитного правила в Payment Manager. Лимиты живут в таблице pm.limit_rule и проверяются модулем src/limits/check-limits.ts при каждом POST /intents ещё ДО создания TB transfer'а. Превышение → 422 LIMIT_EXCEEDED.

1. Когда использовать

Используйте этот рецепт, если нужно:

  • ограничить максимальную сумму одной операции (например, NFC-тап ≤ 300 THB);
  • ограничить дневной/месячный объём определённой операции (например, IPPS withdrawals ≤ 50 000 THB/день);
  • ограничить количество операций в окне (count_limit);
  • ограничить определённую категорию пользователей через теги (tagsInclude / tagsExclude).

Не используйте, если:

  • Нужно жёсткое ограничение amountMin / amountMax на сам маршрут — это payment_route.
  • Нужна fee-логика — это fee_rule.
  • Нужна step-up аутентификация поверх лимита — см. docs/AUTH-POLICIES.md.

2. Пример из реального seed — nfc_per_tap 300 THB на тап

Цель: для канала NFC_CHARGE (один тап карты пользователя у мерчанта) запретить операции > 300 THB. Реальный seed в drizzle/seed.ts:

const limitRules = [
  { name: 'nfc_per_tap', operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
    direction: 'DEBIT' as const, window: 'PER_TX' as const, amountLimit: 30000n },
  { name: 'nfc_daily',   operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
    direction: 'DEBIT' as const, window: 'DAILY'  as const, amountLimit: 150000n },
]

Все суммы в satang (1 THB = 100 satang). 30000n = 300.00 THB.

3. Шаг 1 — выбрать window

limit_rule.window принимает строго одно из трёх значений (CHECK constraint в схеме, см. src/shared/schema.ts:320):

Значение Когда использовать Как считается
PER_TX Ограничение на одну транзакцию. ctx.amount > rule.amountLimit — без обращения к tx_history.
DAILY Объём/количество за сутки UTC. SUM(amount) по tx_history с created_at >= start_of_day_UTC.
MONTHLY Объём/количество за календарный месяц UTC. SUM(amount) по tx_history с created_at >= start_of_month_UTC.

Окно считается в UTC. Старт окна вычисляет функция windowStart() в src/limits/check-limits.ts:

function windowStart(window: 'DAILY' | 'MONTHLY'): Date {
  const now = new Date()
  if (window === 'DAILY')   return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), now.getUTCDate()))
  return new Date(Date.UTC(now.getUTCFullYear(), now.getUTCMonth(), 1))
}

4. Шаг 2 — выбрать direction

direction определяет, какую сторону операции считаем: списание (DEBIT) или зачисление (CREDIT). Допустимы DEBIT, CREDIT, BOTH (CHECK из миграции 0003_fix_limit_rule_direction_char.sql и src/shared/schema.ts:319).

Значение Используется когда Пример
DEBIT Лимит на расход пользователя. NFC-тап (пользователь → мерчант).
CREDIT Лимит на зачисление пользователю. Лимит входящих P2P, AML cooldown.
BOTH Лимит без учёта направления (редко). Общее количество операций.

5. КРИТИЧЕСКИЙ ИНВАРИАНТ — direction определяется через tb_account_map.userId

НИКОГДА не используйте префикс account_name (например user., merchant.) для определения direction.

handler.ts (см. downstream-контракт в комментарии к seed-у nfc_per_tap) вычисляет limitDirection так:

limitDirection = from.tb_account_map.userId === X-User-Id ? 'DEBIT' : 'CREDIT'

То есть это владелец TB-аккаунта, а не его имя. Memory feedback-limit-direction фиксирует: даже если from.account_name = 'user.42.THB', владельцем может быть мерчант — и тогда direction будет CREDIT, а не DEBIT. Префикс имени ничего не гарантирует.

Downstream-контракт для Auth Center / nginx-gateway: заголовок X-User-Id в POST /intents ОБЯЗАН содержать userId той стороны, лимиты которой проверяются. Для NFC_CHARGE это userId покупателя. Если по ошибке передать merchant's userId — limitDirection разрешится в CREDIT, и оба NFC-лимита будут молча обойдены. Это явно задокументировано в seed-комментарии и в плане docs/superpowers/plans/2026-05-22-nfc-charge.md.

6. Шаг 3 — миграция

PM-миграции пишутся только через drizzle-kit — НЕ редактируйте pm.* руками через psql.

6.1. Сгенерировать миграцию

После изменения seed.ts миграция не нужна (это data-seed). Если нужно вставить лимит в существующую БД (без полного re-seed), создайте data-only миграцию:

# в payment-manager/
# вручную создать файл drizzle/migrations/00XX_add_limit_rule_<name>.sql

Содержимое (пример для nfc_per_tap — реально лимит уже в seed, файл показан как образец синтаксиса):

INSERT INTO pm.limit_rule
  (name, operation_type, channel, direction, window, amount_limit)
VALUES
  ('nfc_per_tap', 'NFC_CHARGE', 'INTERNAL_P2P', 'DEBIT', 'PER_TX', 30000)
ON CONFLICT DO NOTHING;

CHECK-ограничения, которые автоматически отбракуют ошибки:

  • limit_rule_direction_chk: direction IN ('DEBIT','CREDIT','BOTH').
  • limit_rule_window_chk: window IN ('DAILY','MONTHLY','PER_TX').
  • limit_rule_has_limit_chk: amount_limit IS NOT NULL OR count_limit IS NOT NULL — нельзя создать пустое правило.

6.2. Применить миграцию

cd projects/payment-manager
npx drizzle-kit migrate

7. Шаг 4 — verify

После apply проверьте, что правило живо и попадает в выборку checkLimits:

-- общая выборка
SELECT id, name, operation_type, channel, direction, window, amount_limit, count_limit, active
FROM pm.limit_rule
WHERE name = 'nfc_per_tap';

-- проверка, что нет дублей
SELECT name, COUNT(*) FROM pm.limit_rule GROUP BY name HAVING COUNT(*) > 1;

Ожидаем ровно одну строку с active = true. checkLimits фильтрует правила по active = true, operation_type (или '*'), channel (или NULL = wildcard), и тегам.

8. Шаг 5 — тесты

Все тесты лимитов живут в test/limits/ — образцы:

  • test/limits/check-limits.test.ts — happy/over-limit пути для PER_TX, DAILY, MONTHLY.
  • test/limits/evaluate-policy.test.ts — связь лимита со step-up политикой.

Минимальный набор для нового правила:

import { describe, it, expect } from 'vitest'
import { checkLimits } from '../../src/limits/check-limits.js'
import { LimitExceededError } from '../../src/shared/errors.js'

describe('limit_rule: nfc_per_tap', () => {
  it('passes when amount <= 300 THB', async () => {
    await checkLimits({
      userId: 1, operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
      direction: 'DEBIT', amount: 30000n, currency: 'THB', tags: [],
    }, db)
    // нет исключения = success
  })

  it('throws LIMIT_EXCEEDED when amount > 300 THB', async () => {
    await expect(checkLimits({
      userId: 1, operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
      direction: 'DEBIT', amount: 30001n, currency: 'THB', tags: [],
    }, db)).rejects.toBeInstanceOf(LimitExceededError)
  })

  it('does NOT apply to CREDIT direction', async () => {
    // merchant получает зачисление — direction=CREDIT, nfc_per_tap=DEBIT → пропуск
    await checkLimits({
      userId: 99, operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
      direction: 'CREDIT', amount: 999999n, currency: 'THB', tags: [],
    }, db) // НЕ должно бросить, что и есть тест на инвариант п.5
  })
})

9. Curl-проверка на живом сервисе

После npm run dev + seed:

curl -X POST http://localhost:3000/intents \
  -H 'Content-Type: application/json' \
  -H 'X-Service-Id: auth-center-merchant' \
  -H 'X-User-Id: 1'                       \
  -H 'X-Timestamp: <unix>'                \
  -H 'X-Signature: <hmac>'                \
  -d '{
        "operationType": "NFC_CHARGE",
        "amount":        30001,
        "currency":      "THB",
        "from":          {"accountName": "user.1.THB"},
        "to":            {"accountName": "merchant.5.THB"}
      }'

Ожидаемый ответ — HTTP 422:

{
  "error":  "LIMIT_EXCEEDED",
  "message": "PER_TX amount limit exceeded: nfc_per_tap",
  "detail": {
    "ruleName":   "nfc_per_tap",
    "window":     "PER_TX",
    "limitType":  "amount",
    "limit":      "30000",
    "current":    "0",
    "requested":  "30001"
  }
}

При amount = 30000200 OK и intent создаётся.

10. Шаг 6 — CHANGELOG

В CHANGELOG.md корня payment-manager/:

## [Unreleased]
### Added
- limit_rule `nfc_per_tap` (PER_TX, DEBIT, NFC_CHARGE@INTERNAL_P2P, 300 THB) — защита от мошеннических больших NFC-таппов.

11. Чек-лист

  • Выбран window: PER_TX / DAILY / MONTHLY (CHECK limit_rule_window_chk).
  • Выбран direction: DEBIT / CREDIT / BOTH (CHECK limit_rule_direction_chk из миграции 0003).
  • Указан хотя бы один из amountLimit или countLimit (CHECK limit_rule_has_limit_chk).
  • name — уникален; добавлен через ON CONFLICT DO NOTHING / по проверке существования (см. seed.ts).
  • Сумма в satang, а не в THB (30000n = 300.00 THB).
  • Инвариант про tb_account_map.userId понят и не нарушен — direction не определяется по префиксу имени аккаунта.
  • Auth Center / источник intent-ов отправляет правильный X-User-Id (для NFC_CHARGE — покупатель, не мерчант).
  • Применена миграция через npx drizzle-kit migrate.
  • SELECT * FROM pm.limit_rule WHERE name='<NAME>' возвращает строку с active=true.
  • Тест в test/limits/ проверяет happy + over-limit + direction-mismatch.
  • Обновлён CHANGELOG.md.

Подводные камни

  • Канал в seed — INTERNAL_P2P, не ADMIN_TRANSFER. ADMIN — это канал ручных корректировок, ADMIN_TRANSFER — это operation_type. Для NFC_CHARGE маршрут идёт через канал INTERNAL_P2P (см. seed.ts → serviceRoutes).
  • channel = NULL в limit_rule означает «любой канал» (wildcard), а не «нет канала». Если правило должно применяться только к одному каналу — указывайте его явно.
  • operationType = '*' означает «любой operation_type». Используйте осторожно — такое правило сработает на каждый intent.
  • Defence-in-depth в check-limits.ts. SQL-WHERE и in-process guards (rule.direction !== ctx.direction и фильтры тегов) дублируют друг друга — это сделано намеренно для совместимости с in-memory mock-БД в тестах. При добавлении нового поля учитывайте оба слоя.
  • record-usage.ts пустой. Использование считается из pm.tx_history on-demand (никаких отдельных счётчиков). Файл оставлен как hook для будущего Redis-кэша; ничего туда писать НЕ нужно.
  • Окно считается в UTC. Бангкокское "сегодня" не совпадает с UTC-сегодня на 7 часов. Если бизнес требует local-time окно — это отдельная задача и нужно менять windowStart().
  • Лимит не блокирует двухфазные reserve(). checkLimits вызывается в момент создания intent'а. Для merchant invoice это значит — проверка происходит при reserve, а не при redeem. Если нужно проверять на redeem — заводить отдельное правило в логике канала.

Ссылки