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 так:
То есть это владелец 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 миграцию:
Содержимое (пример для 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. Применить миграцию¶
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 = 30000 — 200 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(CHECKlimit_rule_window_chk). - Выбран
direction:DEBIT/CREDIT/BOTH(CHECKlimit_rule_direction_chkиз миграции 0003). - Указан хотя бы один из
amountLimitилиcountLimit(CHECKlimit_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_historyon-demand (никаких отдельных счётчиков). Файл оставлен как hook для будущего Redis-кэша; ничего туда писать НЕ нужно.- Окно считается в UTC. Бангкокское "сегодня" не совпадает с UTC-сегодня на 7 часов. Если бизнес требует local-time окно — это отдельная задача и нужно менять
windowStart(). - Лимит не блокирует двухфазные
reserve().checkLimitsвызывается в момент создания intent'а. Для merchant invoice это значит — проверка происходит при reserve, а не при redeem. Если нужно проверять на redeem — заводить отдельное правило в логике канала.
Ссылки¶
- Module: limits
- Reference: limit_rule schema
- Add payment route
- Auth Policies (step-up + limits)
src/limits/check-limits.tsdrizzle/seed.ts— рабочиеnfc_per_tap/nfc_daily- Memory:
feedback-limit-direction— обоснование инварианта проuserId.