12 limit rule
Таблица pm.limit_rule хранит правила лимитов на платежи: per-tx / daily / monthly ограничения по сумме и количеству операций, применяемые до резервирования средств в TigerBeetle.
Имя¶
pm.limit_rule — таблица в схеме pm. Определена в src/shared/schema.ts как Drizzle-модель limitRule (раздел B9: Limit Rule).
Назначение¶
limit_rule — это конфигурация anti-fraud / risk-движка PM. Каждая строка описывает один потолок, который обязательно должен быть проверен до того, как PM создаст pending-перевод в TigerBeetle. Правило отвечает на четыре вопроса:
- на что лимит —
operation_type(NFC_CHARGE,P2P_TRANSFER, …;*= любой) и опциональный фильтр поchannelи тегамintent.metadata.tags; - в каком окне —
window:PER_TX(одна транзакция),DAILY(UTC-сутки),MONTHLY(UTC-месяц); - по какому направлению —
direction:DEBIT(списания со счёта пользователя),CREDIT(зачисления на счёт пользователя),BOTH(любое движение); - что именно ограничить —
amount_limit(потолок суммы в satang) и/илиcount_limit(потолок количества операций). Минимум одно из двух должно быть задано (CHECKlimit_rule_has_limit_chk).
Окно DAILY / MONTHLY считается по факту: сумма берётся из pm.tx_history, отфильтрованной по user_id, currency, direction, operation_type, channel и времени; правее присоединена pm.intent со status = 'SETTLED' (учитываем только подтверждённые операции, не висящие в CREATED/AUTHORIZED). См. src/limits/check-limits.ts.
КРИТИЧЕСКИЙ ИНВАРИАНТ. Поле
direction(DEBIT/CREDIT/BOTH) сравнивается сLimitContext.direction, который вычисляется в обработчике интента по формулеfrom.tb_account_map.userId === X-User-Id ? DEBIT : CREDIT. То есть направление лимита определяется по колонкеuser_idвpm.tb_account_map, а НЕ по префиксуaccount_name(user.,merchant.,agent.). Любая логика видаaccountName.startsWith('user.') → DEBIT— баг: она ломается на merchant/agent-кошельках, у которых тоже естьuser_id, но другой префикс имени. Подтверждение и контекст: memory-записьfeedback-limit-directionи комментарий вdrizzle/seed.ts(NFC_CHARGE).
DDL¶
Из drizzle/migrations/0002_magenta_forgotten_one.sql (создание) и drizzle/migrations/0003_fix_limit_rule_direction_char.sql (правка типа direction):
-- 0002_magenta_forgotten_one.sql
CREATE TABLE "pm"."limit_rule" (
"id" serial PRIMARY KEY NOT NULL,
"name" varchar(100) NOT NULL,
"operation_type" varchar(50) NOT NULL,
"channel" varchar(50),
"direction" char(6) DEFAULT 'DEBIT' NOT NULL,
"window" varchar(16) NOT NULL,
"amount_limit" bigint,
"count_limit" integer,
"tags_include" text[] DEFAULT '{}'::text[] NOT NULL,
"tags_exclude" text[] DEFAULT '{}'::text[] NOT NULL,
"priority" integer DEFAULT 0 NOT NULL,
"active" boolean DEFAULT true NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "limit_rule_direction_chk" CHECK ("pm"."limit_rule"."direction" IN ('DEBIT','CREDIT','BOTH')),
CONSTRAINT "limit_rule_window_chk" CHECK ("pm"."limit_rule"."window" IN ('DAILY','MONTHLY','PER_TX')),
CONSTRAINT "limit_rule_has_limit_chk" CHECK ("pm"."limit_rule"."amount_limit" IS NOT NULL OR "pm"."limit_rule"."count_limit" IS NOT NULL)
);
-- 0003_fix_limit_rule_direction_char.sql — переключение direction с фиксированного CHAR(6)
-- на VARCHAR(6), чтобы избежать padding пробелами (CHAR(6) хранил 'DEBIT '/'CREDIT' с trailing-space,
-- что ломало сравнение в JS-коде).
ALTER TABLE "pm"."limit_rule" ALTER COLUMN "direction" SET DATA TYPE varchar(6);
ALTER TABLE "pm"."limit_rule" ALTER COLUMN "direction" SET DEFAULT 'DEBIT';
Drizzle-описание — src/shared/schema.ts (раздел B9: Limit Rule).
Поля¶
| Колонка | Тип | NULL | Default | Описание |
|---|---|---|---|---|
id |
serial |
NO | nextval(...) |
PK, технический. |
name |
varchar(100) |
NO | — | Человекочитаемое имя (например, nfc_per_tap, nfc_daily). В коде используется для идемпотентности seed-а и в тексте LimitExceededError. Не уникально на уровне БД — уникальность обеспечивается процедурой seed-а. |
operation_type |
varchar(50) |
NO | — | Тип операции, для которого правило проверяется. Спец-значение * — правило применяется к любому operation_type. Должен совпасть с intent.operation_type. |
channel |
varchar(50) |
YES | NULL |
Дополнительный фильтр по каналу (INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, ADMIN, …). NULL = правило применяется ко всем каналам. |
direction |
varchar(6) |
NO | 'DEBIT' |
Направление в терминах tx_history.direction. CHECK ограничивает значения: 'DEBIT' | 'CREDIT' | 'BOTH'. Сравнивается с LimitContext.direction, который выводится из tb_account_map.user_id (см. инвариант выше). |
window |
varchar(16) |
NO | — | Окно агрегации. CHECK: 'DAILY' | 'MONTHLY' | 'PER_TX'. DAILY / MONTHLY границы — по UTC (см. windowStart() в check-limits.ts). PER_TX — потолок на одну транзакцию (countLimit для PER_TX бессмыслен — фактически интерпретируется как «не больше 1 операции», но не используется на практике). |
amount_limit |
bigint |
YES | NULL |
Потолок суммы в satang (1 THB = 100 satang). Сравнивается с SUM(tx_history.amount) + ctx.amount. |
count_limit |
integer |
YES | NULL |
Потолок количества операций в окне. Сравнивается с COUNT(*) + 1. |
tags_include |
text[] |
NO | '{}' |
Если непусто — правило сработает только если ВСЕ теги массива есть в intent.metadata.tags (оператор <@). |
tags_exclude |
text[] |
NO | '{}' |
Если непусто — правило НЕ сработает, если хотя бы один тег из массива есть в intent.metadata.tags (оператор &&). |
priority |
integer |
NO | 0 |
Не используется в текущей реализации check-limits.ts — правила оцениваются в порядке ORDER BY id. Зарезервировано для будущего разрешения конфликтов / cut-off. |
active |
boolean |
NO | true |
Soft-disable. Неактивные правила исключаются из выборки. |
created_at |
timestamptz |
NO | now() |
Время создания строки. |
amount_limit vs count_limit: правило может задавать одно из ограничений или оба. CHECK limit_rule_has_limit_chk гарантирует, что хотя бы одно задано — пустое правило без ограничений отвергается на уровне БД (защита от случайного «пропустить всё»).
Индексы¶
В таблице нет индексов кроме PK — limit_rule ожидаемо содержит десятки, максимум сотни строк, любой запрос завершается seq-scan-ом. Если правил станет много, разумно добавить partial-index (operation_type, channel) WHERE active.
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.
Связи¶
Логических FK нет — правила привязываются к платежу косвенно:
pm.limit_rule.operation_type↔pm.intent.operation_type— выборка кандидатов;pm.limit_rule.channel↔pm.intent.channel— дополнительный фильтр;pm.limit_rule.direction↔LimitContext.direction, вычисляемый изpm.tb_account_map.user_id(полеfrom_account→tb_account_map→ сравнениеuser_idсX-User-Idинтента);- источник «уже потрачено» —
pm.tx_history, отфильтрованная поuserId,currency,direction,operationType,channelи времени окна, с JOIN наpm.intentWHEREstatus = 'SETTLED'.
Связанный код¶
| Файл | Что делает |
|---|---|
src/limits/types.ts |
Тип LimitContext — вход в проверку: userId, operationType, channel, direction (DEBIT|CREDIT), amount, currency, tags. |
src/limits/check-limits.ts |
checkLimits(ctx, db). Загружает активные правила, фильтрует по operationType (* или точный), channel (NULL или совпадение), тегам; для каждого PER_TX сравнивает ctx.amount с amountLimit; для DAILY/MONTHLY агрегирует tx_history (JOIN на intent, фильтр intent.status = 'SETTLED') и сравнивает sumUsed + ctx.amount / countUsed + 1 с потолком. Бросает LimitExceededError. |
src/limits/record-usage.ts |
recordUsage() — на сегодня no-op: использование лимита выводится из pm.tx_history на лету, отдельных счётчиков нет. Зарезервирован под Redis-кеш на будущее. |
Где правило связывается с конкретным пользователем-плательщиком (DEBIT) или получателем (CREDIT): в обработчике интента вызывающий код берёт from.account_name, резолвит его в tb_account_map и сравнивает user_id строки с X-User-Id запроса. Если совпадают — это DEBIT (со счёта пользователя списываем), иначе — CREDIT (на счёт пользователя зачисляем). Результат подставляется в LimitContext.direction перед вызовом checkLimits(). См. подробнее в ./06-tb-account-map.md.
Примеры запросов / seed-строки¶
Seed: NFC_CHARGE per-tap и daily ceilings¶
Засеяно в 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 }, // 300 THB / тап
{ name: 'nfc_daily', operationType: 'NFC_CHARGE', channel: 'INTERNAL_P2P',
direction: 'DEBIT' as const, window: 'DAILY' as const, amountLimit: 150000n }, // 1 500 THB / сутки
]
Эквивалентный SQL (для ручной инициализации/проверки):
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),
('nfc_daily', 'NFC_CHARGE', 'INTERNAL_P2P', 'DEBIT', 'DAILY', 150000)
ON CONFLICT DO NOTHING;
Почему именно DEBIT: NFC-tap — это pull-charge от мерчанта к кошельку пользователя. С точки зрения пользователя это списание. Auth Center должен прислать в заголовке X-User-Id идентификатор покупателя (а не мерчанта); тогда from.tb_account_map.user_id === X-User-Id, и LimitContext.direction = DEBIT — оба NFC-лимита срабатывают. Если по ошибке прислать user_id мерчанта, direction станет CREDIT, оба правила фильтруются на этапе SQL-выборки, и лимиты молча обходятся. Это явный downstream-контракт, задокументированный комментарием в seed-е.
Просмотр активных правил для конкретного (operationType, channel)¶
SELECT id, name, direction, window, amount_limit, count_limit, tags_include, tags_exclude
FROM pm.limit_rule
WHERE active = true
AND (operation_type = '*' OR operation_type = 'NFC_CHARGE')
AND (channel IS NULL OR channel = 'INTERNAL_P2P')
ORDER BY id;
Сколько user уже «съел» за день по NFC¶
SELECT COALESCE(SUM(h.amount), 0) AS used_satang,
COUNT(*) AS used_count
FROM pm.tx_history h
JOIN pm.intent i ON i.id = h.intent_id
WHERE h.user_id = $1
AND h.currency = 'THB'
AND h.direction = 'DEBIT'
AND h.operation_type = 'NFC_CHARGE'
AND i.channel = 'INTERNAL_P2P'
AND i.status = 'SETTLED'
AND h.created_at >= date_trunc('day', now() AT TIME ZONE 'UTC') AT TIME ZONE 'UTC';
Этот запрос воспроизводит ту же агрегацию, что выполняет checkLimits для nfc_daily. Если результат used_satang + новая_сумма > 150000 — LimitExceededError.
Деактивировать правило¶
После этого правило исчезает из выборки checkLimits без удаления исторических данных.