02 intent
Корневая таблица оркестрации платежей: одно платёжное намерение = одна строка pm.intent.
Имя таблицы¶
pm.intent
Назначение¶
Хранит каждое платёжное намерение от его создания (CREATED) до финального состояния (SETTLED / FAILED / CANCELED / EXPIRED). Это корневая сущность всей платёжной саги: вокруг intent.id (UUID) собираются записи аудита (intent_event), денежной истории (tx_history), очереди двухфазных TB-трансферов (outbox_event) и PSP-state-машины (psp_tx_map).
- Пишут: только Payment Manager:
src/intent/handler.ts—POST /intents(создание и однофазный flow).src/intent/confirm-handler.ts—POST /intents/:id/confirm(двухфазный flowMERCHANT_INVOICE).src/intent/cancel-handler.ts—POST /intents/:id/cancel(отмена инвойса мерчантом / админом).- воркеры в
src/saga/иsrc/psp/— обновляютstatus,tb_transfer_ids,failure_reason. - Читают: PM (idempotency-replay,
GET /intents/:id,GET /intents/:id/events), Admin Panel (через прямой read-only коннект кpm.*), сами воркеры PM. - Не пишет никто кроме PM. Внешние сервисы дёргают
POST /intentsс HMAC — но в БД попадают только через handler-ы PM.
DDL¶
DDL ниже — дословное склеивание миграций, в которых pm.intent создаётся / изменяется. Источник — каталог drizzle/migrations/.
Базовое создание — 0000_init.sql¶
CREATE TABLE "pm"."intent" (
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
"idempotency_key" uuid NOT NULL,
"service_id" varchar(50) NOT NULL,
"user_id" integer NOT NULL,
"operation_type" varchar(50) NOT NULL,
"channel" varchar(50) NOT NULL,
"from_account_name" varchar(100) NOT NULL,
"to_account_name" varchar(100) NOT NULL,
"external_ref" varchar(100),
"amount" bigint NOT NULL,
"currency" char(3) DEFAULT 'THB' NOT NULL,
"tb_ledger" integer NOT NULL,
"tb_transfer_ids" uuid[] DEFAULT '{}'::uuid[] NOT NULL,
"status" varchar(20) DEFAULT 'CREATED' NOT NULL,
"pre_fee_amount" bigint DEFAULT 0 NOT NULL,
"post_fee_amount" bigint DEFAULT 0 NOT NULL,
"failure_reason" text,
"metadata" jsonb DEFAULT '{}'::jsonb,
"from_name" varchar(255),
"to_name" varchar(255),
"comment" varchar(500),
"settlement_date" varchar(8),
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "intent_idempotency_key_service_id_uniq" UNIQUE("idempotency_key","service_id")
);
CREATE INDEX "intent_user_id_created_at_idx" ON "pm"."intent" USING btree ("user_id","created_at");
CREATE INDEX "intent_status_active_idx" ON "pm"."intent" USING btree ("status");
Invoice-поля — 0007_intents_invoice_fields.sql¶
ALTER TABLE "pm"."intent"
ALTER COLUMN "from_account_name" DROP NOT NULL,
ADD COLUMN "version" integer NOT NULL DEFAULT 0,
ADD COLUMN "expires_at" timestamp with time zone,
ADD COLUMN "reserved_at" timestamp with time zone,
ADD COLUMN "canceled_at" timestamp with time zone,
ADD COLUMN "redeemed_at" timestamp with time zone,
ADD COLUMN "cancel_reason" varchar(50),
ADD COLUMN "qr_signature" bytea,
ADD COLUMN "issued_by_user_id" integer,
ADD COLUMN "redeemed_by_user_id" integer;
-- Partial index for active invoices (quick lookup by expiry)
CREATE INDEX IF NOT EXISTS "intent_invoice_expiry_idx"
ON "pm"."intent" ("expires_at")
WHERE "status" = 'CREATED' AND "operation_type" = 'INVOICE_PAYMENT';
-- Partial index for invoice issuance tracking
CREATE INDEX IF NOT EXISTS "intent_issued_by_idx"
ON "pm"."intent" ("issued_by_user_id", "created_at" DESC)
WHERE "operation_type" = 'INVOICE_PAYMENT';
Важно: с миграции 0007
from_account_nameстал NULLable — дляINVOICE_PAYMENTплательщик неизвестен на моментCREATED(выпуск инвойса мерчантом) и заполняется только вconfirm-handler.tsпосле скана QR покупателем.Миграции 0001/0002/0005/0006 таблицу
pm.intentне трогают (0001— perf-tuning дляpsp_tx_map/outbox_event,0002— созданиеlimit_rule,0005—DROP COLUMN secret_hashуservice_key,0006—attributesJSONB уtx_history).
Поля¶
| Поле | Тип | NULL | Default | Описание |
|---|---|---|---|---|
id |
uuid |
no | gen_random_uuid() |
PK; используется как trace_id во всех событиях и в Redis-канале intent.{id} для live-подписки клиента. |
idempotency_key |
uuid |
no | — | Идемпотентность вместе с service_id. Повторный POST с тем же ключом не создаёт новую строку — handler возвращает существующую (200 OK). |
service_id |
varchar(50) |
no | — | Идентификатор вызывающего сервиса из HMAC-заголовка X-Service-Id (соответствует pm.service_key). Используется для авторизации GET /intents/:id (сервис видит только свои интенты). |
user_id |
integer |
no | — | Инициатор операции (X-User-Id). Для INVOICE_PAYMENT сначала равен мерчанту (выпустившему инвойс), затем в confirm-handler.ts переписывается на payerUserId. Для admin-операций (channel=ADMIN) равен 0. |
operation_type |
varchar(50) |
no | — | Бизнес-смысл операции (P2P, MERCHANT_PAYMENT, INVOICE_PAYMENT, BANK_DEPOSIT, BANK_WITHDRAWAL, MINIAPP_CHARGE, MINIAPP_REFUND, ADMIN_TRANSFER …). Реестр — src/intent/operation-registry.ts. |
channel |
varchar(50) |
no | — | Канал исполнения, выбранный роутером по (operation_type, amount) через pm.payment_route. Значения из src/channels/*.ts: INTERNAL_P2P, IPPS_TRANSFER, SERVICE_TRANSFER, ADMIN, MERCHANT_INVOICE. |
from_account_name |
varchar(100) |
yes | — | Источник в pm.tb_account_map. NULL у INVOICE_PAYMENT в статусе CREATED — заполняется при confirm (см. миграцию 0007). |
to_account_name |
varchar(100) |
no | — | Получатель в pm.tb_account_map. Может быть резолвлен из to_tb_account_id (только для сервисов с allowToTbAccountId в permissions). |
external_ref |
varchar(100) |
yes | — | Внешний идентификатор (например, IPPS rqUID, ID банковской транзакции, миниапп-orderId). |
amount |
bigint |
no | — | Сумма операции в satang (1 THB = 100 satang). Никаких numeric/float — финансовый инвариант (см. 01-schema-overview.md, «Конвенции»). |
currency |
char(3) |
no | 'THB' |
Валюта; на 0.x всегда THB. |
tb_ledger |
integer |
no | — | ID TigerBeetle-ledger (например, 1 для THB). Берётся из tb_account_map стороны from (или to, если from ещё неизвестен). |
tb_transfer_ids |
uuid[] |
no | '{}'::uuid[] |
Массив TigerBeetle-transfer ID, созданных в рамках этого интента (включая комиссии и void-операции). |
status |
varchar(20) |
no | 'CREATED' |
Один из 9 статусов (см. ниже). Реализован как VARCHAR(20), не PG enum. |
pre_fee_amount |
bigint |
no | 0 |
Сумма PRE-комиссий (списываются с отправителя дополнительно к amount). Считается через src/rule-engine/fee-calculator.ts. |
post_fee_amount |
bigint |
no | 0 |
Сумма POST-комиссий (вычитаются из приходящей суммы у получателя). |
failure_reason |
text |
yes | — | Заполняется при переходе в FAILED (err.message либо строка из канала). |
metadata |
jsonb |
yes | '{}'::jsonb |
Routing-метаданные интента: recipientUserId, merchantId, tags, appScope, ttlSeconds, miniapp-payload и прочее. Используется в правилах комиссий и лимитов. |
from_name |
varchar(255) |
yes | — | Человекочитаемое имя отправителя (для UI/чеков). |
to_name |
varchar(255) |
yes | — | Человекочитаемое имя получателя. |
comment |
varchar(500) |
yes | — | Комментарий к платежу. |
settlement_date |
varchar(8) |
yes | — | Дата settlement в формате YYYYMMDD, полученная от PSP (актуально для IPPS_TRANSFER). Для не-PSP каналов NULL. |
created_at |
timestamptz |
no | now() |
Момент создания. |
updated_at |
timestamptz |
no | now() |
Обновляется handler-ами и воркерами при каждом переходе статуса. |
version |
integer |
no | 0 |
Оптимистичный конкурент-контроль для двухфазных каналов. confirm-handler.ts использует If-Match заголовок и инкремент version + 1 в UPDATE. |
expires_at |
timestamptz |
yes | — | Срок жизни инвойса (для MERCHANT_INVOICE). После наступления — инвойс может быть переведён в EXPIRED. Используется в partial-индексе intent_invoice_expiry_idx. |
reserved_at |
timestamptz |
yes | — | Момент reserve() двухфазным каналом (фиксация QR-подписи и expires_at). |
canceled_at |
timestamptz |
yes | — | Момент отмены инвойса (заполняется в cancel-handler.ts через TwoPhaseChannel.cancel()). |
redeemed_at |
timestamptz |
yes | — | Момент подтверждения инвойса покупателем (confirm-handler.ts). |
cancel_reason |
varchar(50) |
yes | — | Причина отмены (merchant_canceled, expired, admin_override…). |
qr_signature |
bytea |
yes | — | HMAC-SHA256-подпись QR-кода инвойса (16 байт). Отдаётся клиенту в base64url для генерации QR. Подробнее — в channel-реализации MERCHANT_INVOICE. |
issued_by_user_id |
integer |
yes | — | userId мерчанта, выпустившего инвойс. Используется для авторизации отмены: только эмитент (или actorUserId=0 admin bypass) может вызвать cancel. |
redeemed_by_user_id |
integer |
yes | — | userId покупателя, оплатившего инвойс. Заполняется в confirm-handler.ts. |
Статусы (intent.status)¶
Все 9 значений из типа IntentStatus (см. src/shared/schema.ts строка 90):
| Статус | Когда устанавливается |
|---|---|
CREATED |
Сразу после INSERT в handler-е (а также состояние свежевыпущенного инвойса до confirm). |
VALIDATED |
После успешного расчёта комиссий и преvalidate-проверок. |
AUTHORIZED |
После TigerBeetle-pending transfer (channel.steps[0]) — деньги «заморожены», но не списаны. |
SETTLING |
Внутреннее промежуточное состояние при асинхронной обработке PSP (используется воркерами). |
SETTLED |
Финальный успех — TB-transfer post-нут (или INTERNAL_P2P завершён синхронно). |
FAILED |
Финальная ошибка; failure_reason заполнен. |
MANUAL_REVIEW |
Требует разбора оператором (например, orphan-rqUID после краха в IPPS confirm-окне; см. PspState в схеме). |
CANCELED |
Инвойс отменён мерчантом или админом (см. cancel-handler.ts). |
EXPIRED |
Инвойс не был оплачен до expires_at. |
Состояний
RESERVED,INIT,DONEне существует — только перечисленные 9.
Индексы¶
| Индекс | Тип | Колонки | Назначение |
|---|---|---|---|
intent_pkey |
btree, PK | id |
Поиск по UUID интента. |
intent_idempotency_key_service_id_uniq |
btree, UNIQUE | (idempotency_key, service_id) |
Идемпотентность POST /intents — повтор с тем же ключом возвращает существующий интент (см. handler.ts шаг 2). |
intent_user_id_created_at_idx |
btree | (user_id, created_at) |
История операций пользователя (Admin Panel, выписки, лимиты DAILY/MONTHLY). |
intent_status_active_idx |
btree | (status) |
Сканирование активных интентов воркерами (SETTLING, AUTHORIZED, …). |
intent_invoice_expiry_idx |
btree, partial | (expires_at) WHERE status='CREATED' AND operation_type='INVOICE_PAYMENT' |
Быстрый поиск активных инвойсов с истекающим сроком (для job-а перевода в EXPIRED). |
intent_issued_by_idx |
btree, partial | (issued_by_user_id, created_at DESC) WHERE operation_type='INVOICE_PAYMENT' |
История выпущенных инвойсов мерчанта. |
Оба partial-индекса добавлены миграцией 0007_intents_invoice_fields.sql и существуют только для строк INVOICE_PAYMENT — это даёт минимальный размер индекса и максимальную скорость на профиле «мало инвойсов от общего числа интентов».
Связи¶
Логические (по intent_id UUID, без FK на уровне БД)¶
FK не объявлены, чтобы не блокировать запись событий concurrent-обновлениями. Целостность держится прикладным кодом + индексами на колонке intent_id в дочерних таблицах.
| Связь | Дочерняя таблица | Кто пишет |
|---|---|---|
1 : N |
pm.intent_event — аудит-лог статусов |
writeIntentEvent() из src/intent/intent-events.ts |
1 : N |
pm.tx_history — денежная история по аккаунтам |
writeSettlement() из src/intent/settlement-writer.ts |
1 : N |
pm.outbox_event — очередь post_pending/void_pending для TB |
каналы и OutboxWorker |
1 : 0..1 |
pm.psp_tx_map — state-machine PSP (UNIQUE на intent_id) |
psp-worker (IPPS) |
Внешние логические зависимости (без FK)¶
| Колонка | Куда логически ссылается |
|---|---|
service_id |
pm.service_key.service_id |
from_account_name, to_account_name |
pm.tb_account_map.account_name |
operation_type + channel |
определяется правилом из pm.payment_route; влияет на правила комиссий pm.fee_rule и лимиты pm.limit_rule |
Связанный код¶
| Модуль | Роль |
|---|---|
src/intent/handler.ts |
POST /intents — INSERT строки, шаги CREATED → VALIDATED → AUTHORIZED → SETTLED (или FAILED) для однофазных каналов; для MERCHANT_INVOICE вызывает channel.reserve() и оставляет интент в CREATED с заполненным qr_signature/expires_at. |
src/intent/confirm-handler.ts |
POST /intents/:id/confirm — двухфазный flow MERCHANT_INVOICE. Атомарный UPDATE CREATED → VALIDATED с проверкой expires_at > now(), If-Match-версии, заполнением from_account_name/user_id/redeemed_by_user_id/redeemed_at. Далее запускает channel.redeem() и переводит в AUTHORIZED → SETTLED. |
src/intent/cancel-handler.ts |
POST /intents/:id/cancel — авторизация по issued_by_user_id (либо admin userId=0), вызов TwoPhaseChannel.cancel() (атомарный UPDATE status='CANCELED' WHERE status='CREATED'). |
src/intent/intent-events.ts |
writeIntentEvent() — пишет в pm.intent_event при каждом переходе статуса; publishIntentStatus() — публикует в Redis-канал intent.{id} для live-подписки клиента. |
src/intent/settlement-writer.ts |
writeSettlement() — после SETTLED создаёт строки в pm.tx_history по каждому участвующему аккаунту. |
src/intent/router.ts |
resolveChannel() — выбор channel по operation_type + amount через pm.payment_route. |
src/saga/ |
Воркеры асинхронной обработки (PSP, outbox) — обновляют status, tb_transfer_ids, failure_reason. |
Примеры запросов¶
Получить интент по ID для конкретного сервиса¶
SELECT id, status, channel, operation_type, amount, currency,
pre_fee_amount, post_fee_amount, from_account_name, to_account_name,
created_at, updated_at
FROM pm.intent
WHERE id = $1
AND service_id = $2
LIMIT 1;
GET /intents/:id — handler.ts)
Idempotency-replay при повторном POST /intents¶
SELECT id, status, channel, amount, currency,
pre_fee_amount, post_fee_amount, created_at,
expires_at, qr_signature, version
FROM pm.intent
WHERE idempotency_key = $1
AND service_id = $2
LIMIT 1;
handler.ts)
Атомарное подтверждение инвойса (confirm)¶
UPDATE pm.intent
SET status = 'VALIDATED',
from_account_name = $1,
user_id = $2,
redeemed_by_user_id = $2,
redeemed_at = now(),
version = version + 1,
updated_at = now()
WHERE id = $3
AND status = 'CREATED'
AND operation_type = 'INVOICE_PAYMENT'
AND expires_at > now()
AND version = $4 -- опционально (If-Match)
RETURNING *;
confirm-handler.ts; если 0 строк — handler разбирает причину: ALREADY_PROCESSED / EXPIRED / VERSION_MISMATCH)
Активные инвойсы, истекающие в ближайшие 60 секунд¶
SELECT id, expires_at, to_account_name, amount
FROM pm.intent
WHERE status = 'CREATED'
AND operation_type = 'INVOICE_PAYMENT'
AND expires_at < now() + interval '60 seconds'
ORDER BY expires_at;
intent_invoice_expiry_idx обслуживает этот запрос целиком; partial — поэтому минимального размера)
История выпущенных мерчантом инвойсов¶
SELECT id, status, amount, created_at, expires_at, redeemed_at, canceled_at
FROM pm.intent
WHERE operation_type = 'INVOICE_PAYMENT'
AND issued_by_user_id = $1
ORDER BY created_at DESC
LIMIT 50;
intent_issued_by_idx — partial, отсортирован created_at DESC)
Отладка: текущее распределение по статусам¶
SELECT status, count(*)
FROM pm.intent
WHERE created_at > now() - interval '24 hours'
GROUP BY status
ORDER BY count(*) DESC;
Поиск «зависших» в AUTHORIZED (для эскалации в MANUAL_REVIEW)¶
SELECT id, channel, operation_type, amount, updated_at
FROM pm.intent
WHERE status = 'AUTHORIZED'
AND updated_at < now() - interval '10 minutes'
ORDER BY updated_at;
intent_status_active_idx сужает по status, затем фильтр по updated_at)