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

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 (потолок количества операций). Минимум одно из двух должно быть задано (CHECK limit_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_chkdirection IN ('DEBIT','CREDIT','BOTH');
  • limit_rule_window_chkwindow IN ('DAILY','MONTHLY','PER_TX');
  • limit_rule_has_limit_chkamount_limit IS NOT NULL OR count_limit IS NOT NULL.

Связи

Логических FK нет — правила привязываются к платежу косвенно:

  • pm.limit_rule.operation_typepm.intent.operation_type — выборка кандидатов;
  • pm.limit_rule.channelpm.intent.channel — дополнительный фильтр;
  • pm.limit_rule.directionLimitContext.direction, вычисляемый из pm.tb_account_map.user_id (поле from_accounttb_account_map → сравнение user_id с X-User-Id интента);
  • источник «уже потрачено» — pm.tx_history, отфильтрованная по userId, currency, direction, operationType, channel и времени окна, с JOIN на pm.intent WHERE status = '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 + новая_сумма > 150000LimitExceededError.

Деактивировать правило

UPDATE pm.limit_rule SET active = false WHERE name = 'nfc_per_tap';

После этого правило исчезает из выборки checkLimits без удаления исторических данных.