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) не оправдан.
Связи¶
- Не имеет FK —
operation_typeиchannel— это «полусправочные» строковые перечисления, валидируемые в коде (intent/handler.ts,psp-router/). - Логическая связь с
pm.intent: результатresolveChannel(operationType, amount)записывается вintent.channelпри создании интента. Канал, выбранный по этой таблице, определяет дальнейшую saga:INTERNAL_P2Psettle-ится синхронно,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):
После этого все новые интенты IPPS_WITHDRAWAL будут падать с NoRouteError, что и требуется для аварийной заморозки канала без передеплоя.