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

pm.payment_route

Справочник маршрутов: какой channel обслуживает конкретный operationType для заданного диапазона суммы. Используется функцией resolveChannel() на этапе создания интента.

Имя

pm.payment_route — таблица в схеме pm. Определена в src/shared/schema.ts как Drizzle-модель paymentRoute.

Назначение

Маршрутизация платежа в правильный канал исполнения (channel) выполняется не хардкодом, а data-driven: по парам «операция × сумма». Это позволяет:

  • завести новую операцию или диапазон без изменения кода;
  • временно отключить маршрут флагом active=false (kill-switch для канала);
  • иметь несколько диапазонов сумм для одной операции (например, разные PSP для small/large тикетов) — за счёт уникального ключа (operation_type, amount_min, amount_max).

Содержательная семантика: «для operationType=X и суммы в диапазоне [amount_min; amount_max] использовать канал channel». Если подходящих строк несколько, выбирается строка с максимальным amount_min (наиболее «узкий» вышестоящий диапазон), активный (active=true).

DDL

Из drizzle/migrations/0000_init.sql:

CREATE TABLE "pm"."payment_route" (
    "id" serial PRIMARY KEY NOT NULL,
    "operation_type" varchar(50) NOT NULL,
    "amount_min" bigint DEFAULT 0 NOT NULL,
    "amount_max" bigint DEFAULT 9223372036854775807 NOT NULL,
    "channel" varchar(50) NOT NULL,
    "active" boolean DEFAULT true NOT NULL,
    CONSTRAINT "payment_route_op_amount_uniq" UNIQUE("operation_type","amount_min","amount_max")
);

Поля

Поле Тип Default Назначение
id serial PK Внутренний идентификатор строки.
operation_type varchar(50) Бизнес-операция: P2P_TRANSFER, IPPS_WITHDRAWAL, INVOICE_PAYMENT, и т.д. Никак не FK, валидируется на уровне приложения.
amount_min bigint 0 Нижняя граница суммы в satang (включительно).
amount_max bigint 9223372036854775807 Верхняя граница суммы в satang (включительно). Default = INT64_MAX — «без верха».
channel varchar(50) Куда маршрутизировать: INTERNAL_P2P, IPPS_TRANSFER, MERCHANT_INVOICE, SERVICE_TRANSFER, ADMIN.
active boolean true Флаг включения. false исключает строку из выборки в resolveChannel.

Все суммы хранятся в satang (1 THB = 100 satang) — единый базовый юнит PM.

Индексы

  • PRIMARY KEY (id) — автоматически.
  • UNIQUE (operation_type, amount_min, amount_max)payment_route_op_amount_uniq. Гарантирует, что для одной операции не появится два конфликтующих диапазона с одинаковыми границами. Используется и как идемпотентный target в ON CONFLICT DO NOTHING сидеров (см. drizzle/seed.ts, миграцию 0009).

Других индексов нет: таблица маленькая (десятки строк), Postgres делает Seq Scan, и индекс по (operation_type, active) не оправдан.

Связи

  • Не имеет FKoperation_type и channel — это «полусправочные» строковые перечисления, валидируемые в коде (intent/handler.ts, psp-router/).
  • Логическая связь с pm.intent: результат resolveChannel(operationType, amount) записывается в intent.channel при создании интента. Канал, выбранный по этой таблице, определяет дальнейшую saga: INTERNAL_P2P settle-ится синхронно, IPPS_TRANSFER уходит в psp_tx_map + IPPS-воркер, MERCHANT_INVOICE — в инвойс-flow, и т.д.

Связанный код

Файл / модуль Роль
src/shared/schema.ts Drizzle-определение paymentRoute (типы PaymentRoute, NewPaymentRoute).
src/intent/router.ts resolveChannel(operationType, amount, db) — единственный читатель таблицы.
src/intent/handler.ts Вызывает resolveChannel() при создании интента; результат → intent.channel.
drizzle/seed.ts Сид базовых маршрутов (IPPS / SERVICE / ADMIN / NFC / INVOICE).
drizzle/migrations/0009_invoice_payment_route.sql Идемпотентная миграция-сид для INVOICE_PAYMENT → MERCHANT_INVOICE.

Важно: src/intent/router.ts — это NOT HTTP-роутер, а внутренний резолвер канала. Никаких URL-маршрутов здесь нет.

Поведение resolveChannel():

// src/intent/router.ts (упрощённо)
where: operationType = $1
   AND amount_min   <= $2
   AND amount_max   >= $2
   AND active       = true
orderBy: amount_min DESC
limit:   1

Если строк нет — бросается NoRouteError («No route for ${operationType} amount=${amount}»). ORDER BY amount_min DESC гарантирует, что более узкий вышестоящий диапазон (например, «большой тикет») имеет приоритет над более широким fallback-диапазоном.

Примеры запросов / seed

Сид из drizzle/seed.ts

IPPS-маршруты (1 THB … 200 000 THB):

{ operationType: 'IPPS_WITHDRAWAL', amountMin: 100n, amountMax: 200_000_00n, channel: 'IPPS_TRANSFER' }
{ operationType: 'THAI_QR_PAY',     amountMin: 100n, amountMax: 200_000_00n, channel: 'IPPS_TRANSFER' }

Сервисные маршруты:

{ operationType: 'SERVICE_DEPOSIT', amountMin: 1n, amountMax: 9_999_999_99n,        channel: 'SERVICE_TRANSFER' }
{ operationType: 'ADMIN_TRANSFER',  amountMin: 1n, amountMax: 9_999_999_99n,        channel: 'ADMIN' }
{ operationType: 'NFC_CHARGE',      amountMin: 0n, amountMax: 9223372036854775807n, channel: 'INTERNAL_P2P' }

Замечание: для NFC_CHARGE диапазон намеренно «без верха» — per-tap / daily лимиты enforced через pm.limit_rule (см. 12-limit-rule.md).

Сид инвойсов из миграции 0009

-- drizzle/migrations/0009_invoice_payment_route.sql
INSERT INTO pm.payment_route (operation_type, amount_min, amount_max, channel, active)
VALUES ('INVOICE_PAYMENT', 1, 100000000, 'MERCHANT_INVOICE', true)
ON CONFLICT (operation_type, amount_min, amount_max) DO NOTHING;

INVOICE_PAYMENT всегда уходит в канал MERCHANT_INVOICE (отдельная инвойс-saga в merchant/); диапазон 1 satang … 1 000 000 THB.

Полезные SELECT'ы

Все активные маршруты:

SELECT operation_type, amount_min, amount_max, channel
FROM   pm.payment_route
WHERE  active = true
ORDER  BY operation_type, amount_min;

Симуляция resolveChannel('IPPS_WITHDRAWAL', 50000) (500 THB):

SELECT channel
FROM   pm.payment_route
WHERE  operation_type = 'IPPS_WITHDRAWAL'
  AND  amount_min <= 50000
  AND  amount_max >= 50000
  AND  active = true
ORDER  BY amount_min DESC
LIMIT  1;
-- → IPPS_TRANSFER

Временно выключить канал для одной операции (kill-switch):

UPDATE pm.payment_route
SET    active = false
WHERE  operation_type = 'IPPS_WITHDRAWAL';

После этого все новые интенты IPPS_WITHDRAWAL будут падать с NoRouteError, что и требуется для аварийной заморозки канала без передеплоя.